app.py 88 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052
  1. import binascii
  2. import json
  3. import logging
  4. import mimetypes
  5. import os
  6. import traceback
  7. import urllib
  8. from datetime import datetime
  9. from datetime import timedelta
  10. from datetime import timezone
  11. from functools import wraps
  12. from io import BytesIO
  13. from typing import Any
  14. from typing import Dict
  15. from typing import Optional
  16. from typing import Tuple
  17. from urllib.parse import urlencode
  18. from urllib.parse import urlparse
  19. from requests.exceptions import HTTPError
  20. import requests
  21. import bleach
  22. import mf2py
  23. import pymongo
  24. import timeago
  25. from bson.objectid import ObjectId
  26. from dateutil import parser
  27. from flask import Flask
  28. from flask import make_response
  29. from flask import Response
  30. from flask import abort
  31. from flask import jsonify as flask_jsonify
  32. from flask import redirect
  33. from flask import render_template
  34. from flask import request
  35. from flask import session
  36. from flask import url_for
  37. from flask_wtf.csrf import CSRFProtect
  38. from html2text import html2text
  39. from itsdangerous import BadSignature
  40. from little_boxes import activitypub as ap
  41. from little_boxes.activitypub import ActivityType
  42. from little_boxes.activitypub import _to_list
  43. from little_boxes.activitypub import clean_activity
  44. from little_boxes.activitypub import format_datetime
  45. from little_boxes.activitypub import get_backend
  46. from little_boxes.content_helper import parse_markdown
  47. from little_boxes.linked_data_sig import generate_signature
  48. from little_boxes.errors import ActivityGoneError
  49. from little_boxes.errors import NotAnActivityError
  50. from little_boxes.errors import BadActivityError
  51. from little_boxes.errors import ActivityNotFoundError
  52. from little_boxes.errors import Error
  53. from little_boxes.errors import NotFromOutboxError
  54. from little_boxes.httpsig import HTTPSigAuth
  55. from little_boxes.httpsig import verify_request
  56. from little_boxes.webfinger import get_actor_url
  57. from little_boxes.webfinger import get_remote_follow_template
  58. from utils import opengraph
  59. from passlib.hash import bcrypt
  60. from u2flib_server import u2f
  61. from werkzeug.utils import secure_filename
  62. import activitypub
  63. import config
  64. from activitypub import Box
  65. from activitypub import embed_collection
  66. from activitypub import _answer_key
  67. from config import USER_AGENT
  68. from config import ADMIN_API_KEY
  69. from config import BASE_URL
  70. from config import DB
  71. from config import DEBUG_MODE
  72. from config import DOMAIN
  73. from config import EMOJIS
  74. from config import HEADERS
  75. from config import ICON_URL
  76. from config import ID
  77. from config import JWT
  78. from config import KEY
  79. from config import ME
  80. from config import MEDIA_CACHE
  81. from config import PASS
  82. from config import USERNAME
  83. from config import VERSION
  84. from config import PUBLIC_DOMAIN
  85. from config import ACTOR_URL
  86. from config import PORT
  87. from config import _drop_db
  88. from utils.key import get_secret_key
  89. from utils.lookup import lookup
  90. from utils.media import Kind
  91. from poussetaches import PousseTaches
  92. p = PousseTaches(
  93. os.getenv("MICROBLOGPUB_POUSSETACHES_HOST", "http://localhost:7991"),f"http://localhost:{PORT}",
  94. )
  95. # p = PousseTaches("http://localhost:7991", "http://localhost:5000")
  96. back = activitypub.MicroblogPubBackend()
  97. ap.use_backend(back)
  98. MY_PERSON = ap.Person(**ME)
  99. app = Flask(__name__)
  100. app.secret_key = get_secret_key("flask")
  101. app.config.update(WTF_CSRF_CHECK_DEFAULT=False)
  102. csrf = CSRFProtect(app)
  103. logger = logging.getLogger(__name__)
  104. # Hook up Flask logging with gunicorn
  105. root_logger = logging.getLogger()
  106. if os.getenv("FLASK_DEBUG"):
  107. logger.setLevel(logging.DEBUG)
  108. root_logger.setLevel(logging.DEBUG)
  109. else:
  110. gunicorn_logger = logging.getLogger("gunicorn.error")
  111. root_logger.handlers = gunicorn_logger.handlers
  112. root_logger.setLevel(gunicorn_logger.level)
  113. SIG_AUTH = HTTPSigAuth(KEY)
  114. def verify_pass(pwd):
  115. return bcrypt.verify(pwd, PASS)
  116. @app.context_processor
  117. def inject_config():
  118. q = {
  119. "type": "Create",
  120. "activity.object.inReplyTo": None,
  121. "meta.deleted": False,
  122. "meta.public": True,
  123. }
  124. notes_count = DB.activities.find(
  125. {"box": Box.OUTBOX.value, "$or": [q, {"type": "Announce", "meta.undo": False}]}
  126. ).count()
  127. with_replies_count = DB.activities.find(
  128. {
  129. "box": Box.OUTBOX.value,
  130. "type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
  131. "meta.undo": False,
  132. "meta.deleted": False,
  133. "meta.public": True,
  134. }
  135. ).count()
  136. liked_count = DB.activities.count(
  137. {
  138. "box": Box.OUTBOX.value,
  139. "meta.deleted": False,
  140. "meta.undo": False,
  141. "type": ActivityType.LIKE.value,
  142. }
  143. )
  144. followers_q = {
  145. "box": Box.INBOX.value,
  146. "type": ActivityType.FOLLOW.value,
  147. "meta.undo": False,
  148. }
  149. following_q = {
  150. "box": Box.OUTBOX.value,
  151. "type": ActivityType.FOLLOW.value,
  152. "meta.undo": False,
  153. }
  154. return dict(
  155. microblogpub_version=VERSION,
  156. config=config,
  157. logged_in=session.get("logged_in", False),
  158. followers_count=DB.activities.count(followers_q),
  159. following_count=DB.activities.count(following_q),
  160. notes_count=notes_count,
  161. liked_count=liked_count,
  162. with_replies_count=with_replies_count,
  163. me=ME,
  164. base_url=config.BASE_URL,
  165. )
  166. @app.after_request
  167. def set_x_powered_by(response):
  168. response.headers["X-Powered-By"] = "microblog.pub"
  169. return response
  170. # HTML/templates helper
  171. ALLOWED_TAGS = [
  172. "a",
  173. "abbr",
  174. "acronym",
  175. "b",
  176. "br",
  177. "blockquote",
  178. "code",
  179. "pre",
  180. "em",
  181. "i",
  182. "li",
  183. "ol",
  184. "strong",
  185. "ul",
  186. "span",
  187. "div",
  188. "p",
  189. "h1",
  190. "h2",
  191. "h3",
  192. "h4",
  193. "h5",
  194. "h6",
  195. ]
  196. def clean_html(html):
  197. try:
  198. return bleach.clean(html, tags=ALLOWED_TAGS)
  199. except Exception:
  200. return ""
  201. _GRIDFS_CACHE: Dict[Tuple[Kind, str, Optional[int]], str] = {}
  202. def _get_file_url(url, size, kind):
  203. k = (kind, url, size)
  204. cached = _GRIDFS_CACHE.get(k)
  205. if cached:
  206. return cached
  207. doc = MEDIA_CACHE.get_file(url, size, kind)
  208. if doc:
  209. u = f"/media/{str(doc._id)}"
  210. _GRIDFS_CACHE[k] = u
  211. return u
  212. # MEDIA_CACHE.cache(url, kind)
  213. app.logger.error(f"cache not available for {url}/{size}/{kind}")
  214. return url
  215. @app.template_filter()
  216. def gtone(n):
  217. return n > 1
  218. @app.template_filter()
  219. def gtnow(dtstr):
  220. return format_datetime(datetime.now().astimezone()) > dtstr
  221. @app.template_filter()
  222. def remove_mongo_id(dat):
  223. if isinstance(dat, list):
  224. return [remove_mongo_id(item) for item in dat]
  225. if "_id" in dat:
  226. dat["_id"] = str(dat["_id"])
  227. for k, v in dat.items():
  228. if isinstance(v, dict):
  229. dat[k] = remove_mongo_id(dat[k])
  230. return dat
  231. @app.template_filter()
  232. def get_video_link(data):
  233. for link in data:
  234. if link.get("mimeType", "").startswith("video/"):
  235. return link.get("href")
  236. return None
  237. @app.template_filter()
  238. def get_actor_icon_url(url, size):
  239. return _get_file_url(url, size, Kind.ACTOR_ICON)
  240. @app.template_filter()
  241. def get_attachment_url(url, size):
  242. return _get_file_url(url, size, Kind.ATTACHMENT)
  243. @app.template_filter()
  244. def get_og_image_url(url, size=100):
  245. try:
  246. return _get_file_url(url, size, Kind.OG_IMAGE)
  247. except Exception:
  248. return ""
  249. @app.template_filter()
  250. def permalink_id(val):
  251. return str(hash(val))
  252. @app.template_filter()
  253. def quote_plus(t):
  254. return urllib.parse.quote_plus(t)
  255. @app.template_filter()
  256. def is_from_outbox(t):
  257. return t.startswith(ID)
  258. @app.template_filter()
  259. def clean(html):
  260. return clean_html(html)
  261. @app.template_filter()
  262. def html2plaintext(body):
  263. return html2text(body)
  264. @app.template_filter()
  265. def domain(url):
  266. return urlparse(url).netloc
  267. @app.template_filter()
  268. def url_or_id(d):
  269. if isinstance(d, dict):
  270. if ("url" in d) and isinstance(d["url"], str):
  271. return d["url"]
  272. else:
  273. return d["id"]
  274. return ""
  275. @app.template_filter()
  276. def get_url(u):
  277. print(f"GET_URL({u!r})")
  278. if isinstance(u, list):
  279. for l in u:
  280. if l.get("mimeType") == "text/html":
  281. u = l
  282. if isinstance(u, dict):
  283. return u["href"]
  284. elif isinstance(u, str):
  285. return u
  286. else:
  287. return u
  288. @app.template_filter()
  289. def get_actor(url):
  290. if not url:
  291. return None
  292. if isinstance(url, list):
  293. url = url[0]
  294. if isinstance(url, dict):
  295. url = url.get("id")
  296. print(f"GET_ACTOR {url}")
  297. try:
  298. return get_backend().fetch_iri(url)
  299. except (ActivityNotFoundError, ActivityGoneError):
  300. return f"Deleted<{url}>"
  301. except Exception as exc:
  302. return f"Error<{url}/{exc!r}>"
  303. @app.template_filter()
  304. def format_time(val):
  305. if val:
  306. dt = parser.parse(val)
  307. return datetime.strftime(dt, "%B %d, %Y, %H:%M %p")
  308. return val
  309. @app.template_filter()
  310. def format_timeago(val):
  311. if val:
  312. dt = parser.parse(val)
  313. return timeago.format(dt, datetime.now(timezone.utc))
  314. return val
  315. @app.template_filter()
  316. def has_type(doc, _types):
  317. for _type in _to_list(_types):
  318. if _type in _to_list(doc["type"]):
  319. return True
  320. return False
  321. @app.template_filter()
  322. def has_actor_type(doc):
  323. for t in ap.ACTOR_TYPES:
  324. if has_type(doc, t.value):
  325. return True
  326. return False
  327. def _is_img(filename):
  328. filename = filename.lower()
  329. if (
  330. filename.endswith(".png")
  331. or filename.endswith(".jpg")
  332. or filename.endswith(".jpeg")
  333. or filename.endswith(".gif")
  334. or filename.endswith(".svg")
  335. ):
  336. return True
  337. return False
  338. @app.template_filter()
  339. def not_only_imgs(attachment):
  340. for a in attachment:
  341. if isinstance(a, dict) and not _is_img(a["url"]):
  342. return True
  343. if isinstance(a, str) and not _is_img(a):
  344. return True
  345. return False
  346. @app.template_filter()
  347. def is_img(filename):
  348. return _is_img(filename)
  349. @app.template_filter()
  350. def get_answer_count(choice, meta):
  351. print(choice, meta)
  352. return meta.get("question_answers", {}).get(_answer_key(choice), 0)
  353. def add_response_headers(headers={}):
  354. """This decorator adds the headers passed in to the response"""
  355. def decorator(f):
  356. @wraps(f)
  357. def decorated_function(*args, **kwargs):
  358. resp = make_response(f(*args, **kwargs))
  359. h = resp.headers
  360. for header, value in headers.items():
  361. h[header] = value
  362. return resp
  363. return decorated_function
  364. return decorator
  365. def noindex(f):
  366. """This decorator passes X-Robots-Tag: noindex, nofollow"""
  367. return add_response_headers({"X-Robots-Tag": "noindex, nofollow"})(f)
  368. def login_required(f):
  369. @wraps(f)
  370. def decorated_function(*args, **kwargs):
  371. if not session.get("logged_in"):
  372. return redirect(url_for("admin_login", next=request.url))
  373. return f(*args, **kwargs)
  374. return decorated_function
  375. def _api_required():
  376. if session.get("logged_in"):
  377. if request.method not in ["GET", "HEAD"]:
  378. # If a standard API request is made with a "login session", it must havw a CSRF token
  379. csrf.protect()
  380. return
  381. # Token verification
  382. token = request.headers.get("Authorization", "").replace("Bearer ", "")
  383. if not token:
  384. # IndieAuth token
  385. token = request.form.get("access_token", "")
  386. # Will raise a BadSignature on bad auth
  387. payload = JWT.loads(token)
  388. logger.info(f"api call by {payload}")
  389. def api_required(f):
  390. @wraps(f)
  391. def decorated_function(*args, **kwargs):
  392. try:
  393. _api_required()
  394. except BadSignature:
  395. abort(401)
  396. return f(*args, **kwargs)
  397. return decorated_function
  398. def jsonify(**data):
  399. if "@context" not in data:
  400. data["@context"] = config.DEFAULT_CTX
  401. return Response(
  402. response=json.dumps(data),
  403. headers={
  404. "Content-Type": "application/json"
  405. if app.debug
  406. else "application/activity+json"
  407. },
  408. )
  409. def is_api_request():
  410. h = request.headers.get("Accept")
  411. if h is None:
  412. return False
  413. h = h.split(",")[0]
  414. if h in HEADERS or h == "application/json":
  415. return True
  416. return False
  417. @app.errorhandler(ValueError)
  418. def handle_value_error(error):
  419. logger.error(
  420. f"caught value error: {error!r}, {traceback.format_tb(error.__traceback__)}"
  421. )
  422. response = flask_jsonify(message=error.args[0])
  423. response.status_code = 400
  424. return response
  425. @app.errorhandler(Error)
  426. def handle_activitypub_error(error):
  427. logger.error(
  428. f"caught activitypub error {error!r}, {traceback.format_tb(error.__traceback__)}"
  429. )
  430. response = flask_jsonify(error.to_dict())
  431. response.status_code = error.status_code
  432. return response
  433. class TaskError(Exception):
  434. """Raised to log the error for poussetaches."""
  435. def __init__(self):
  436. self.message = traceback.format_exc()
  437. @app.errorhandler(TaskError)
  438. def handle_task_error(error):
  439. logger.error(
  440. f"caught activitypub error {error!r}, {traceback.format_tb(error.__traceback__)}"
  441. )
  442. response = flask_jsonify({"traceback": error.message})
  443. response.status_code = 500
  444. return response
  445. # @app.errorhandler(Exception)
  446. # def handle_other_error(error):
  447. # logger.error(
  448. # f"caught error {error!r}, {traceback.format_tb(error.__traceback__)}"
  449. # )
  450. # response = flask_jsonify({})
  451. # response.status_code = 500
  452. # return response
  453. # App routes
  454. ROBOTS_TXT = """User-agent: *
  455. Disallow: /login
  456. Disallow: /admin/
  457. Disallow: /static/
  458. Disallow: /media/
  459. Disallow: /uploads/"""
  460. @app.route("/robots.txt")
  461. def robots_txt():
  462. return Response(response=ROBOTS_TXT, headers={"Content-Type": "text/plain"})
  463. @app.route("/media/<media_id>")
  464. @noindex
  465. def serve_media(media_id):
  466. f = MEDIA_CACHE.fs.get(ObjectId(media_id))
  467. resp = app.response_class(f, direct_passthrough=True, mimetype=f.content_type)
  468. resp.headers.set("Content-Length", f.length)
  469. resp.headers.set("ETag", f.md5)
  470. resp.headers.set(
  471. "Last-Modified", f.uploadDate.strftime("%a, %d %b %Y %H:%M:%S GMT")
  472. )
  473. resp.headers.set("Cache-Control", "public,max-age=31536000,immutable")
  474. resp.headers.set("Content-Encoding", "gzip")
  475. return resp
  476. @app.route("/uploads/<oid>/<fname>")
  477. def serve_uploads(oid, fname):
  478. f = MEDIA_CACHE.fs.get(ObjectId(oid))
  479. resp = app.response_class(f, direct_passthrough=True, mimetype=f.content_type)
  480. resp.headers.set("Content-Length", f.length)
  481. resp.headers.set("ETag", f.md5)
  482. resp.headers.set(
  483. "Last-Modified", f.uploadDate.strftime("%a, %d %b %Y %H:%M:%S GMT")
  484. )
  485. resp.headers.set("Cache-Control", "public,max-age=31536000,immutable")
  486. resp.headers.set("Content-Encoding", "gzip")
  487. return resp
  488. #######
  489. # Login
  490. @app.route("/admin/logout")
  491. @login_required
  492. def admin_logout():
  493. session["logged_in"] = False
  494. return redirect("/")
  495. @app.route("/login", methods=["POST", "GET"])
  496. @noindex
  497. def admin_login():
  498. if session.get("logged_in") is True:
  499. return redirect(url_for("admin_notifications"))
  500. devices = [doc["device"] for doc in DB.u2f.find()]
  501. u2f_enabled = True if devices else False
  502. if request.method == "POST":
  503. csrf.protect()
  504. # 1. Check regular password login flow
  505. pwd = request.form.get("pass")
  506. if pwd:
  507. if verify_pass(pwd):
  508. session["logged_in"] = True
  509. return redirect(
  510. request.args.get("redirect") or url_for("admin_notifications")
  511. )
  512. else:
  513. abort(403)
  514. # 2. Check for U2F payload, if any
  515. elif devices:
  516. resp = json.loads(request.form.get("resp"))
  517. try:
  518. u2f.complete_authentication(session["challenge"], resp)
  519. except ValueError as exc:
  520. print("failed", exc)
  521. abort(403)
  522. return
  523. finally:
  524. session["challenge"] = None
  525. session["logged_in"] = True
  526. return redirect(
  527. request.args.get("redirect") or url_for("admin_notifications")
  528. )
  529. else:
  530. abort(401)
  531. payload = None
  532. if devices:
  533. payload = u2f.begin_authentication(ID, devices)
  534. session["challenge"] = payload
  535. return render_template("login.html", u2f_enabled=u2f_enabled, payload=payload)
  536. @app.route("/remote_follow", methods=["GET", "POST"])
  537. def remote_follow():
  538. if request.method == "GET":
  539. return render_template("remote_follow.html")
  540. csrf.protect()
  541. profile = request.form.get("profile")
  542. if not profile.startswith("@"):
  543. profile = f"@{profile}"
  544. return redirect(
  545. get_remote_follow_template(profile).format(uri=f"{USERNAME}@{DOMAIN}")
  546. )
  547. @app.route("/authorize_follow", methods=["GET", "POST"])
  548. @login_required
  549. def authorize_follow():
  550. if request.method == "GET":
  551. return render_template(
  552. "authorize_remote_follow.html", profile=request.args.get("profile")
  553. )
  554. actor = get_actor_url(request.form.get("profile"))
  555. if not actor:
  556. abort(500)
  557. q = {
  558. "box": Box.OUTBOX.value,
  559. "type": ActivityType.FOLLOW.value,
  560. "meta.undo": False,
  561. "activity.object": actor,
  562. }
  563. if DB.activities.count(q) > 0:
  564. return redirect("/following")
  565. follow = ap.Follow(actor=MY_PERSON.id, object=actor)
  566. post_to_outbox(follow)
  567. return redirect("/following")
  568. @app.route("/u2f/register", methods=["GET", "POST"])
  569. @login_required
  570. def u2f_register():
  571. # TODO(tsileo): ensure no duplicates
  572. if request.method == "GET":
  573. payload = u2f.begin_registration(ID)
  574. session["challenge"] = payload
  575. return render_template("u2f.html", payload=payload)
  576. else:
  577. resp = json.loads(request.form.get("resp"))
  578. device, device_cert = u2f.complete_registration(session["challenge"], resp)
  579. session["challenge"] = None
  580. DB.u2f.insert_one({"device": device, "cert": device_cert})
  581. session["logged_in"] = False
  582. return redirect("/login")
  583. #######
  584. # Activity pub routes
  585. @app.route("/drop_cache")
  586. @login_required
  587. def drop_cache():
  588. DB.actors.drop()
  589. return "Done"
  590. def paginated_query(db, q, limit=25, sort_key="_id"):
  591. older_than = newer_than = None
  592. query_sort = -1
  593. first_page = not request.args.get("older_than") and not request.args.get(
  594. "newer_than"
  595. )
  596. query_older_than = request.args.get("older_than")
  597. query_newer_than = request.args.get("newer_than")
  598. if query_older_than:
  599. q["_id"] = {"$lt": ObjectId(query_older_than)}
  600. elif query_newer_than:
  601. q["_id"] = {"$gt": ObjectId(query_newer_than)}
  602. query_sort = 1
  603. outbox_data = list(db.find(q, limit=limit + 1).sort(sort_key, query_sort))
  604. outbox_len = len(outbox_data)
  605. outbox_data = sorted(
  606. outbox_data[:limit], key=lambda x: str(x[sort_key]), reverse=True
  607. )
  608. if query_older_than:
  609. newer_than = str(outbox_data[0]["_id"])
  610. if outbox_len == limit + 1:
  611. older_than = str(outbox_data[-1]["_id"])
  612. elif query_newer_than:
  613. older_than = str(outbox_data[-1]["_id"])
  614. if outbox_len == limit + 1:
  615. newer_than = str(outbox_data[0]["_id"])
  616. elif first_page and outbox_len == limit + 1:
  617. older_than = str(outbox_data[-1]["_id"])
  618. return outbox_data, older_than, newer_than
  619. CACHING = True
  620. def _get_cached(type_="html", arg=None):
  621. if not CACHING:
  622. return None
  623. logged_in = session.get("logged_in")
  624. if not logged_in:
  625. cached = DB.cache2.find_one({"path": request.path, "type": type_, "arg": arg})
  626. if cached:
  627. app.logger.info("from cache")
  628. return cached["response_data"]
  629. return None
  630. def _cache(resp, type_="html", arg=None):
  631. if not CACHING:
  632. return None
  633. logged_in = session.get("logged_in")
  634. if not logged_in:
  635. DB.cache2.update_one(
  636. {"path": request.path, "type": type_, "arg": arg},
  637. {"$set": {"response_data": resp, "date": datetime.now(timezone.utc)}},
  638. upsert=True,
  639. )
  640. return None
  641. @app.route("/")
  642. def index():
  643. if is_api_request():
  644. return jsonify(**ME)
  645. cache_arg = (
  646. f"{request.args.get('older_than', '')}:{request.args.get('newer_than', '')}"
  647. )
  648. cached = _get_cached("html", cache_arg)
  649. if cached:
  650. return cached
  651. q = {
  652. "box": Box.OUTBOX.value,
  653. "type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
  654. "activity.object.inReplyTo": None,
  655. "meta.deleted": False,
  656. "meta.undo": False,
  657. "$or": [{"meta.pinned": False}, {"meta.pinned": {"$exists": False}}],
  658. }
  659. print(list(DB.activities.find(q)))
  660. pinned = []
  661. # Only fetch the pinned notes if we're on the first page
  662. if not request.args.get("older_than") and not request.args.get("newer_than"):
  663. q_pinned = {
  664. "box": Box.OUTBOX.value,
  665. "type": ActivityType.CREATE.value,
  666. "meta.deleted": False,
  667. "meta.undo": False,
  668. "meta.pinned": True,
  669. }
  670. pinned = list(DB.activities.find(q_pinned))
  671. outbox_data, older_than, newer_than = paginated_query(
  672. DB.activities, q, limit=config.LIMIT
  673. )
  674. resp = render_template(
  675. "index.html",
  676. outbox_data=outbox_data,
  677. older_than=older_than,
  678. newer_than=newer_than,
  679. pinned=pinned,
  680. )
  681. _cache(resp, "html", cache_arg)
  682. return resp
  683. @app.route("/with_replies")
  684. @login_required
  685. def with_replies():
  686. q = {
  687. "box": Box.OUTBOX.value,
  688. "type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
  689. "meta.deleted": False,
  690. "meta.public": True,
  691. "meta.undo": False,
  692. }
  693. outbox_data, older_than, newer_than = paginated_query(DB.activities, q, limit=config.LIMIT)
  694. return render_template(
  695. "index.html",
  696. outbox_data=outbox_data,
  697. older_than=older_than,
  698. newer_than=newer_than,
  699. )
  700. def _build_thread(data, include_children=True): # noqa: C901
  701. data["_requested"] = True
  702. app.logger.info(f"_build_thread({data!r})")
  703. root_id = data["meta"].get("thread_root_parent", data["activity"]["object"]["id"])
  704. query = {
  705. "$or": [{"meta.thread_root_parent": root_id}, {"activity.object.id": root_id}],
  706. "meta.deleted": False,
  707. }
  708. replies = [data]
  709. for dat in DB.activities.find(query):
  710. if dat["type"][0] == ActivityType.CREATE.value:
  711. replies.append(dat)
  712. else:
  713. # Make a Note/Question/... looks like a Create
  714. dat = {
  715. "activity": {"object": dat["activity"]},
  716. "meta": dat["meta"],
  717. "_id": dat["_id"],
  718. }
  719. replies.append(dat)
  720. replies = sorted(replies, key=lambda d: d["activity"]["object"]["published"])
  721. # Index all the IDs in order to build a tree
  722. idx = {}
  723. replies2 = []
  724. for rep in replies:
  725. rep_id = rep["activity"]["object"]["id"]
  726. if rep_id in idx:
  727. continue
  728. idx[rep_id] = rep.copy()
  729. idx[rep_id]["_nodes"] = []
  730. replies2.append(rep)
  731. # Build the tree
  732. for rep in replies2:
  733. rep_id = rep["activity"]["object"]["id"]
  734. if rep_id == root_id:
  735. continue
  736. reply_of = ap._get_id(rep["activity"]["object"]["inReplyTo"])
  737. try:
  738. idx[reply_of]["_nodes"].append(rep)
  739. except KeyError:
  740. app.logger.info(f"{reply_of} is not there! skipping {rep}")
  741. # Flatten the tree
  742. thread = []
  743. def _flatten(node, level=0):
  744. node["_level"] = level
  745. thread.append(node)
  746. for snode in sorted(
  747. idx[node["activity"]["object"]["id"]]["_nodes"],
  748. key=lambda d: d["activity"]["object"]["published"],
  749. ):
  750. _flatten(snode, level=level + 1)
  751. try:
  752. _flatten(idx[root_id])
  753. except KeyError:
  754. app.logger.info(f"{root_id} is not there! skipping")
  755. return thread
  756. @app.route("/note/<note_id>")
  757. def note_by_id(note_id):
  758. if is_api_request():
  759. return redirect(url_for("outbox_activity", item_id=note_id))
  760. data = DB.activities.find_one(
  761. {"box": Box.OUTBOX.value, "remote_id": back.activity_url(note_id)}
  762. )
  763. if not data:
  764. abort(404)
  765. if data["meta"].get("deleted", False):
  766. abort(410)
  767. thread = _build_thread(data)
  768. app.logger.info(f"thread={thread!r}")
  769. raw_likes = list(
  770. DB.activities.find(
  771. {
  772. "meta.undo": False,
  773. "meta.deleted": False,
  774. "type": ActivityType.LIKE.value,
  775. "$or": [
  776. # FIXME(tsileo): remove all the useless $or
  777. {"activity.object.id": data["activity"]["object"]["id"]},
  778. {"activity.object": data["activity"]["object"]["id"]},
  779. ],
  780. }
  781. )
  782. )
  783. likes = []
  784. for doc in raw_likes:
  785. try:
  786. likes.append(doc["meta"]["actor"])
  787. except Exception:
  788. app.logger.exception(f"invalid doc: {doc!r}")
  789. app.logger.info(f"likes={likes!r}")
  790. raw_shares = list(
  791. DB.activities.find(
  792. {
  793. "meta.undo": False,
  794. "meta.deleted": False,
  795. "type": ActivityType.ANNOUNCE.value,
  796. "$or": [
  797. {"activity.object.id": data["activity"]["object"]["id"]},
  798. {"activity.object": data["activity"]["object"]["id"]},
  799. ],
  800. }
  801. )
  802. )
  803. shares = []
  804. for doc in raw_shares:
  805. try:
  806. shares.append(doc["meta"]["actor"])
  807. except Exception:
  808. app.logger.exception(f"invalid doc: {doc!r}")
  809. app.logger.info(f"shares={shares!r}")
  810. return render_template(
  811. "note.html", likes=likes, shares=shares, thread=thread, note=data
  812. )
  813. @app.route("/nodeinfo")
  814. def nodeinfo():
  815. response = _get_cached("api")
  816. cached = True
  817. if not response:
  818. cached = False
  819. q = {
  820. "box": Box.OUTBOX.value,
  821. "meta.deleted": False, # TODO(tsileo): retrieve deleted and expose tombstone
  822. "type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
  823. }
  824. response = json.dumps(
  825. {
  826. "version": "2.0",
  827. "software": {
  828. "name": "microblogpub",
  829. "version": f"Microblog.pub {VERSION}",
  830. },
  831. "protocols": ["activitypub"],
  832. "services": {"inbound": [], "outbound": []},
  833. "openRegistrations": False,
  834. "usage": {"users": {"total": 1}, "localPosts": DB.activities.count(q)},
  835. "metadata": {
  836. "sourceCode": config.SOURCE_URL,
  837. "nodeName": f"@{USERNAME}@{DOMAIN}",
  838. },
  839. }
  840. )
  841. if not cached:
  842. _cache(response, "api")
  843. return Response(
  844. headers={
  845. "Content-Type": "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#"
  846. },
  847. response=response,
  848. )
  849. @app.route("/.well-known/nodeinfo")
  850. def wellknown_nodeinfo():
  851. return flask_jsonify(
  852. links=[
  853. {
  854. "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
  855. "href": f"{ID}/nodeinfo",
  856. }
  857. ]
  858. )
  859. @app.route("/.well-known/webfinger")
  860. def wellknown_webfinger():
  861. """Enable WebFinger support, required for Mastodon interopability."""
  862. # TODO(tsileo): move this to little-boxes?
  863. resource = request.args.get("resource")
  864. if resource not in [f"acct:{USERNAME}@{DOMAIN}",f"acct:{USERNAME}@{PUBLIC_DOMAIN}",ID,ACTOR_URL]:
  865. abort(404)
  866. out = {
  867. "subject": f"acct:{USERNAME}@{DOMAIN}",
  868. "aliases": [ID],
  869. "links": [
  870. {
  871. "rel": "http://webfinger.net/rel/profile-page",
  872. "type": "text/html",
  873. "href": BASE_URL,
  874. },
  875. {"rel": "self", "type": "application/activity+json", "href": ID},
  876. {
  877. "rel": "http://ostatus.org/schema/1.0/subscribe",
  878. "template": BASE_URL + "/authorize_follow?profile={uri}",
  879. },
  880. {"rel": "magic-public-key", "href": KEY.to_magic_key()},
  881. {
  882. "href": ICON_URL,
  883. "rel": "http://webfinger.net/rel/avatar",
  884. "type": mimetypes.guess_type(ICON_URL)[0],
  885. },
  886. ],
  887. }
  888. return Response(
  889. response=json.dumps(out),
  890. headers={
  891. "Content-Type": "application/jrd+json; charset=utf-8"
  892. if not app.debug
  893. else "application/json"
  894. },
  895. )
  896. def add_extra_collection(raw_doc: Dict[str, Any]) -> Dict[str, Any]:
  897. if raw_doc["activity"]["type"] != ActivityType.CREATE.value:
  898. return raw_doc
  899. raw_doc["activity"]["object"]["replies"] = embed_collection(
  900. raw_doc.get("meta", {}).get("count_direct_reply", 0),
  901. f'{raw_doc["remote_id"]}/replies',
  902. )
  903. raw_doc["activity"]["object"]["likes"] = embed_collection(
  904. raw_doc.get("meta", {}).get("count_like", 0), f'{raw_doc["remote_id"]}/likes'
  905. )
  906. raw_doc["activity"]["object"]["shares"] = embed_collection(
  907. raw_doc.get("meta", {}).get("count_boost", 0), f'{raw_doc["remote_id"]}/shares'
  908. )
  909. return raw_doc
  910. def remove_context(activity: Dict[str, Any]) -> Dict[str, Any]:
  911. if "@context" in activity:
  912. del activity["@context"]
  913. return activity
  914. def _add_answers_to_questions(raw_doc: Dict[str, Any]) -> None:
  915. activity = raw_doc["activity"]
  916. if (
  917. ap._has_type(activity["type"], ActivityType.CREATE)
  918. and "object" in activity
  919. and ap._has_type(activity["object"]["type"], ActivityType.QUESTION)
  920. ):
  921. for choice in activity["object"].get("oneOf", activity["object"].get("anyOf")):
  922. choice["replies"] = {
  923. "type": ActivityType.COLLECTION.value,
  924. "totalItems": raw_doc["meta"]
  925. .get("question_answers", {})
  926. .get(_answer_key(choice["name"]), 0),
  927. }
  928. now = datetime.now().astimezone()
  929. if format_datetime(now) > activity["object"]["endTime"]:
  930. activity["object"]["closed"] = activity["object"]["endTime"]
  931. def activity_from_doc(raw_doc: Dict[str, Any], embed: bool = False) -> Dict[str, Any]:
  932. raw_doc = add_extra_collection(raw_doc)
  933. activity = clean_activity(raw_doc["activity"])
  934. # Handle Questions
  935. # TODO(tsileo): what about object embedded by ID/URL?
  936. _add_answers_to_questions(raw_doc)
  937. if embed:
  938. return remove_context(activity)
  939. return activity
  940. @app.route("/outbox", methods=["GET", "POST"])
  941. def outbox():
  942. if request.method == "GET":
  943. if not is_api_request():
  944. abort(404)
  945. # TODO(tsileo): returns the whole outbox if authenticated
  946. q = {
  947. "box": Box.OUTBOX.value,
  948. "meta.deleted": False,
  949. "meta.undo": False,
  950. "meta.public": True,
  951. "type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
  952. }
  953. return jsonify(
  954. **activitypub.build_ordered_collection(
  955. DB.activities,
  956. q=q,
  957. cursor=request.args.get("cursor"),
  958. map_func=lambda doc: activity_from_doc(doc, embed=True),
  959. col_name="outbox",
  960. )
  961. )
  962. # Handle POST request
  963. try:
  964. _api_required()
  965. except BadSignature:
  966. abort(401)
  967. data = request.get_json(force=True)
  968. print(data)
  969. activity = ap.parse_activity(data)
  970. activity_id = post_to_outbox(activity)
  971. return Response(status=201, headers={"Location": activity_id})
  972. @app.route("/outbox/<item_id>")
  973. def outbox_detail(item_id):
  974. doc = DB.activities.find_one(
  975. {"box": Box.OUTBOX.value, "remote_id": back.activity_url(item_id)}
  976. )
  977. if not doc:
  978. abort(404)
  979. if doc["meta"].get("deleted", False):
  980. obj = ap.parse_activity(doc["activity"])
  981. resp = jsonify(**obj.get_tombstone().to_dict())
  982. resp.status_code = 410
  983. return resp
  984. return jsonify(**activity_from_doc(doc))
  985. @app.route("/outbox/<item_id>/activity")
  986. def outbox_activity(item_id):
  987. data = DB.activities.find_one(
  988. {"box": Box.OUTBOX.value, "remote_id": back.activity_url(item_id)}
  989. )
  990. if not data:
  991. abort(404)
  992. obj = activity_from_doc(data)
  993. if data["meta"].get("deleted", False):
  994. obj = ap.parse_activity(data["activity"])
  995. resp = jsonify(**obj.get_object().get_tombstone().to_dict())
  996. resp.status_code = 410
  997. return resp
  998. if obj["type"] != ActivityType.CREATE.value:
  999. abort(404)
  1000. return jsonify(**obj["object"])
  1001. @app.route("/outbox/<item_id>/replies")
  1002. def outbox_activity_replies(item_id):
  1003. if not is_api_request():
  1004. abort(404)
  1005. data = DB.activities.find_one(
  1006. {
  1007. "box": Box.OUTBOX.value,
  1008. "remote_id": back.activity_url(item_id),
  1009. "meta.deleted": False,
  1010. }
  1011. )
  1012. if not data:
  1013. abort(404)
  1014. obj = ap.parse_activity(data["activity"])
  1015. if obj.ACTIVITY_TYPE != ActivityType.CREATE:
  1016. abort(404)
  1017. q = {
  1018. "meta.deleted": False,
  1019. "type": ActivityType.CREATE.value,
  1020. "activity.object.inReplyTo": obj.get_object().id,
  1021. }
  1022. return jsonify(
  1023. **activitypub.build_ordered_collection(
  1024. DB.activities,
  1025. q=q,
  1026. cursor=request.args.get("cursor"),
  1027. map_func=lambda doc: doc["activity"]["object"],
  1028. col_name=f"outbox/{item_id}/replies",
  1029. first_page=request.args.get("page") == "first",
  1030. )
  1031. )
  1032. @app.route("/outbox/<item_id>/likes")
  1033. def outbox_activity_likes(item_id):
  1034. if not is_api_request():
  1035. abort(404)
  1036. data = DB.activities.find_one(
  1037. {
  1038. "box": Box.OUTBOX.value,
  1039. "remote_id": back.activity_url(item_id),
  1040. "meta.deleted": False,
  1041. }
  1042. )
  1043. if not data:
  1044. abort(404)
  1045. obj = ap.parse_activity(data["activity"])
  1046. if obj.ACTIVITY_TYPE != ActivityType.CREATE:
  1047. abort(404)
  1048. q = {
  1049. "meta.undo": False,
  1050. "type": ActivityType.LIKE.value,
  1051. "$or": [
  1052. {"activity.object.id": obj.get_object().id},
  1053. {"activity.object": obj.get_object().id},
  1054. ],
  1055. }
  1056. return jsonify(
  1057. **activitypub.build_ordered_collection(
  1058. DB.activities,
  1059. q=q,
  1060. cursor=request.args.get("cursor"),
  1061. map_func=lambda doc: remove_context(doc["activity"]),
  1062. col_name=f"outbox/{item_id}/likes",
  1063. first_page=request.args.get("page") == "first",
  1064. )
  1065. )
  1066. @app.route("/outbox/<item_id>/shares")
  1067. def outbox_activity_shares(item_id):
  1068. if not is_api_request():
  1069. abort(404)
  1070. data = DB.activities.find_one(
  1071. {
  1072. "box": Box.OUTBOX.value,
  1073. "remote_id": back.activity_url(item_id),
  1074. "meta.deleted": False,
  1075. }
  1076. )
  1077. if not data:
  1078. abort(404)
  1079. obj = ap.parse_activity(data["activity"])
  1080. if obj.ACTIVITY_TYPE != ActivityType.CREATE:
  1081. abort(404)
  1082. q = {
  1083. "meta.undo": False,
  1084. "type": ActivityType.ANNOUNCE.value,
  1085. "$or": [
  1086. {"activity.object.id": obj.get_object().id},
  1087. {"activity.object": obj.get_object().id},
  1088. ],
  1089. }
  1090. return jsonify(
  1091. **activitypub.build_ordered_collection(
  1092. DB.activities,
  1093. q=q,
  1094. cursor=request.args.get("cursor"),
  1095. map_func=lambda doc: remove_context(doc["activity"]),
  1096. col_name=f"outbox/{item_id}/shares",
  1097. first_page=request.args.get("page") == "first",
  1098. )
  1099. )
  1100. @app.route("/admin", methods=["GET"])
  1101. @login_required
  1102. def admin():
  1103. q = {
  1104. "meta.deleted": False,
  1105. "meta.undo": False,
  1106. "type": ActivityType.LIKE.value,
  1107. "box": Box.OUTBOX.value,
  1108. }
  1109. col_liked = DB.activities.count(q)
  1110. return render_template(
  1111. "admin.html",
  1112. instances=list(DB.instances.find()),
  1113. inbox_size=DB.activities.count({"box": Box.INBOX.value}),
  1114. outbox_size=DB.activities.count({"box": Box.OUTBOX.value}),
  1115. col_liked=col_liked,
  1116. col_followers=DB.activities.count(
  1117. {
  1118. "box": Box.INBOX.value,
  1119. "type": ActivityType.FOLLOW.value,
  1120. "meta.undo": False,
  1121. }
  1122. ),
  1123. col_following=DB.activities.count(
  1124. {
  1125. "box": Box.OUTBOX.value,
  1126. "type": ActivityType.FOLLOW.value,
  1127. "meta.undo": False,
  1128. }
  1129. ),
  1130. )
  1131. @app.route("/admin/tasks", methods=["GET"])
  1132. @login_required
  1133. def admin_tasks():
  1134. return render_template(
  1135. "admin_tasks.html",
  1136. success=p.get_success(),
  1137. dead=p.get_dead(),
  1138. waiting=p.get_waiting(),
  1139. cron=p.get_cron(),
  1140. )
  1141. @app.route("/admin/lookup", methods=["GET", "POST"])
  1142. @login_required
  1143. def admin_lookup():
  1144. data = None
  1145. meta = None
  1146. if request.method == "POST":
  1147. if request.form.get("url"):
  1148. data = lookup(request.form.get("url"))
  1149. if data.has_type(ActivityType.ANNOUNCE):
  1150. meta = dict(
  1151. object=data.get_object().to_dict(),
  1152. object_actor=data.get_object().get_actor().to_dict(),
  1153. actor=data.get_actor().to_dict(),
  1154. )
  1155. print(data)
  1156. app.logger.debug(data.to_dict())
  1157. return render_template(
  1158. "lookup.html", data=data, meta=meta, url=request.form.get("url")
  1159. )
  1160. @app.route("/admin/thread")
  1161. @login_required
  1162. def admin_thread():
  1163. data = DB.activities.find_one(
  1164. {
  1165. "type": ActivityType.CREATE.value,
  1166. "activity.object.id": request.args.get("oid"),
  1167. }
  1168. )
  1169. if not data:
  1170. abort(404)
  1171. if data["meta"].get("deleted", False):
  1172. abort(410)
  1173. thread = _build_thread(data)
  1174. tpl = "note.html"
  1175. if request.args.get("debug"):
  1176. tpl = "note_debug.html"
  1177. return render_template(tpl, thread=thread, note=data)
  1178. @app.route("/admin/new", methods=["GET"])
  1179. @login_required
  1180. def admin_new():
  1181. reply_id = None
  1182. content = ""
  1183. thread = []
  1184. print(request.args)
  1185. if request.args.get("reply"):
  1186. data = DB.activities.find_one({"activity.object.id": request.args.get("reply")})
  1187. if data:
  1188. reply = ap.parse_activity(data["activity"])
  1189. else:
  1190. data = dict(
  1191. meta={},
  1192. activity=dict(
  1193. object=get_backend().fetch_iri(request.args.get("reply"))
  1194. ),
  1195. )
  1196. reply = ap.parse_activity(data["activity"]["object"])
  1197. reply_id = reply.id
  1198. if reply.ACTIVITY_TYPE == ActivityType.CREATE:
  1199. reply_id = reply.get_object().id
  1200. actor = reply.get_actor()
  1201. domain = urlparse(actor.id).netloc
  1202. # FIXME(tsileo): if reply of reply, fetch all participants
  1203. content = f"@{actor.preferredUsername}@{domain} "
  1204. thread = _build_thread(data)
  1205. return render_template(
  1206. "new.html",
  1207. reply=reply_id,
  1208. content=content,
  1209. thread=thread,
  1210. emojis=EMOJIS.split(" "),
  1211. )
  1212. @app.route("/admin/notifications")
  1213. @login_required
  1214. def admin_notifications():
  1215. # Setup the cron for deleting old activities
  1216. # FIXME(tsileo): put back to 12h
  1217. p.push({}, "/task/cleanup", schedule="@every 1h")
  1218. # Trigger a cleanup if asked
  1219. if request.args.get("cleanup"):
  1220. p.push({}, "/task/cleanup")
  1221. # FIXME(tsileo): show unfollow (performed by the current actor) and liked???
  1222. mentions_query = {
  1223. "type": ActivityType.CREATE.value,
  1224. "activity.object.tag.type": "Mention",
  1225. "activity.object.tag.name": f"@{USERNAME}@{DOMAIN}",
  1226. "meta.deleted": False,
  1227. }
  1228. replies_query = {
  1229. "type": ActivityType.CREATE.value,
  1230. "activity.object.inReplyTo": {"$regex": f"^{BASE_URL}"},
  1231. }
  1232. announced_query = {
  1233. "type": ActivityType.ANNOUNCE.value,
  1234. "activity.object": {"$regex": f"^{BASE_URL}"},
  1235. }
  1236. new_followers_query = {"type": ActivityType.FOLLOW.value}
  1237. unfollow_query = {
  1238. "type": ActivityType.UNDO.value,
  1239. "activity.object.type": ActivityType.FOLLOW.value,
  1240. }
  1241. likes_query = {
  1242. "type": ActivityType.LIKE.value,
  1243. "activity.object": {"$regex": f"^{BASE_URL}"},
  1244. }
  1245. followed_query = {"type": ActivityType.ACCEPT.value}
  1246. q = {
  1247. "box": Box.INBOX.value,
  1248. "$or": [
  1249. mentions_query,
  1250. announced_query,
  1251. replies_query,
  1252. new_followers_query,
  1253. followed_query,
  1254. unfollow_query,
  1255. likes_query,
  1256. ],
  1257. }
  1258. inbox_data, older_than, newer_than = paginated_query(DB.activities, q)
  1259. return render_template(
  1260. "stream.html",
  1261. inbox_data=inbox_data,
  1262. older_than=older_than,
  1263. newer_than=newer_than,
  1264. )
  1265. @app.route("/api/key")
  1266. @login_required
  1267. def api_user_key():
  1268. return flask_jsonify(api_key=ADMIN_API_KEY)
  1269. def _user_api_arg(key: str, **kwargs):
  1270. """Try to get the given key from the requests, try JSON body, form data and query arg."""
  1271. if request.is_json:
  1272. oid = request.json.get(key)
  1273. else:
  1274. oid = request.args.get(key) or request.form.get(key)
  1275. if not oid:
  1276. if "default" in kwargs:
  1277. app.logger.info(f'{key}={kwargs.get("default")}')
  1278. return kwargs.get("default")
  1279. raise ValueError(f"missing {key}")
  1280. app.logger.info(f"{key}={oid}")
  1281. return oid
  1282. def _user_api_get_note(from_outbox: bool = False):
  1283. oid = _user_api_arg("id")
  1284. app.logger.info(f"fetching {oid}")
  1285. note = ap.parse_activity(get_backend().fetch_iri(oid))
  1286. if from_outbox and not note.id.startswith(ID):
  1287. raise NotFromOutboxError(
  1288. f"cannot load {note.id}, id must be owned by the server"
  1289. )
  1290. return note
  1291. def _user_api_response(**kwargs):
  1292. _redirect = _user_api_arg("redirect", default=None)
  1293. if _redirect:
  1294. return redirect(_redirect)
  1295. resp = flask_jsonify(**kwargs)
  1296. resp.status_code = 201
  1297. return resp
  1298. @app.route("/api/note/delete", methods=["POST"])
  1299. @api_required
  1300. def api_delete():
  1301. """API endpoint to delete a Note activity."""
  1302. note = _user_api_get_note(from_outbox=True)
  1303. delete = ap.Delete(actor=ID, object=ap.Tombstone(id=note.id).to_dict(embed=True))
  1304. delete_id = post_to_outbox(delete)
  1305. return _user_api_response(activity=delete_id)
  1306. @app.route("/api/boost", methods=["POST"])
  1307. @api_required
  1308. def api_boost():
  1309. note = _user_api_get_note()
  1310. announce = note.build_announce(MY_PERSON)
  1311. announce_id = post_to_outbox(announce)
  1312. return _user_api_response(activity=announce_id)
  1313. @app.route("/api/vote", methods=["POST"])
  1314. @api_required
  1315. def api_vote():
  1316. oid = _user_api_arg("id")
  1317. app.logger.info(f"fetching {oid}")
  1318. note = ap.parse_activity(get_backend().fetch_iri(oid))
  1319. choice = _user_api_arg("choice")
  1320. raw_note = dict(
  1321. attributedTo=MY_PERSON.id,
  1322. cc=[],
  1323. to=note.get_actor().id,
  1324. name=choice,
  1325. tag=[],
  1326. inReplyTo=note.id,
  1327. )
  1328. note = ap.Note(**raw_note)
  1329. create = note.build_create()
  1330. create_id = post_to_outbox(create)
  1331. return _user_api_response(activity=create_id)
  1332. @app.route("/api/like", methods=["POST"])
  1333. @api_required
  1334. def api_like():
  1335. note = _user_api_get_note()
  1336. like = note.build_like(MY_PERSON)
  1337. like_id = post_to_outbox(like)
  1338. return _user_api_response(activity=like_id)
  1339. @app.route("/api/note/pin", methods=["POST"])
  1340. @api_required
  1341. def api_pin():
  1342. note = _user_api_get_note(from_outbox=True)
  1343. DB.activities.update_one(
  1344. {"activity.object.id": note.id, "box": Box.OUTBOX.value},
  1345. {"$set": {"meta.pinned": True}},
  1346. )
  1347. return _user_api_response(pinned=True)
  1348. @app.route("/api/note/unpin", methods=["POST"])
  1349. @api_required
  1350. def api_unpin():
  1351. note = _user_api_get_note(from_outbox=True)
  1352. DB.activities.update_one(
  1353. {"activity.object.id": note.id, "box": Box.OUTBOX.value},
  1354. {"$set": {"meta.pinned": False}},
  1355. )
  1356. return _user_api_response(pinned=False)
  1357. @app.route("/api/undo", methods=["POST"])
  1358. @api_required
  1359. def api_undo():
  1360. oid = _user_api_arg("id")
  1361. doc = DB.activities.find_one(
  1362. {
  1363. "box": Box.OUTBOX.value,
  1364. "$or": [{"remote_id": back.activity_url(oid)}, {"remote_id": oid}],
  1365. }
  1366. )
  1367. if not doc:
  1368. raise ActivityNotFoundError(f"cannot found {oid}")
  1369. obj = ap.parse_activity(doc.get("activity"))
  1370. # FIXME(tsileo): detect already undo-ed and make this API call idempotent
  1371. undo = obj.build_undo()
  1372. undo_id = post_to_outbox(undo)
  1373. return _user_api_response(activity=undo_id)
  1374. @app.route("/admin/stream")
  1375. @login_required
  1376. def admin_stream():
  1377. q = {"meta.stream": True, "meta.deleted": False}
  1378. tpl = "stream.html"
  1379. if request.args.get("debug"):
  1380. tpl = "stream_debug.html"
  1381. if request.args.get("debug_inbox"):
  1382. q = {}
  1383. inbox_data, older_than, newer_than = paginated_query(
  1384. DB.activities, q, limit=int(request.args.get("limit", 25))
  1385. )
  1386. return render_template(
  1387. tpl, inbox_data=inbox_data, older_than=older_than, newer_than=newer_than
  1388. )
  1389. @app.route("/inbox", methods=["GET", "POST"]) # noqa: C901
  1390. def inbox():
  1391. if request.method == "GET":
  1392. if not is_api_request():
  1393. abort(404)
  1394. try:
  1395. _api_required()
  1396. except BadSignature:
  1397. abort(404)
  1398. return jsonify(
  1399. **activitypub.build_ordered_collection(
  1400. DB.activities,
  1401. q={"meta.deleted": False, "box": Box.INBOX.value},
  1402. cursor=request.args.get("cursor"),
  1403. map_func=lambda doc: remove_context(doc["activity"]),
  1404. col_name="inbox",
  1405. )
  1406. )
  1407. data = request.get_json(force=True)
  1408. logger.debug(f"req_headers={request.headers}")
  1409. logger.debug(f"raw_data={data}")
  1410. try:
  1411. if not verify_request(
  1412. request.method, request.path, request.headers, request.data
  1413. ):
  1414. raise Exception("failed to verify request")
  1415. except Exception:
  1416. logger.exception(
  1417. "failed to verify request, trying to verify the payload by fetching the remote"
  1418. )
  1419. try:
  1420. data = get_backend().fetch_iri(data["id"])
  1421. except ActivityGoneError:
  1422. # XXX Mastodon sends Delete activities that are not dereferencable, it's the actor url with #delete
  1423. # appended, so an `ActivityGoneError` kind of ensure it's "legit"
  1424. if data["type"] == ActivityType.DELETE.value and data["id"].startswith(
  1425. data["object"]
  1426. ):
  1427. logger.info(f"received a Delete for an actor {data!r}")
  1428. if get_backend().inbox_check_duplicate(MY_PERSON, data["id"]):
  1429. # The activity is already in the inbox
  1430. logger.info(f"received duplicate activity {data!r}, dropping it")
  1431. DB.activities.insert_one(
  1432. {
  1433. "box": Box.INBOX.value,
  1434. "activity": data,
  1435. "type": _to_list(data["type"]),
  1436. "remote_id": data["id"],
  1437. "meta": {"undo": False, "deleted": False},
  1438. }
  1439. )
  1440. # TODO(tsileo): write the callback the the delete external actor event
  1441. return Response(status=201)
  1442. except Exception:
  1443. logger.exception(f'failed to fetch remote id at {data["id"]}')
  1444. return Response(
  1445. status=422,
  1446. headers={"Content-Type": "application/json"},
  1447. response=json.dumps(
  1448. {
  1449. "error": "failed to verify request (using HTTP signatures or fetching the IRI)"
  1450. }
  1451. ),
  1452. )
  1453. print(data)
  1454. activity = ap.parse_activity(data)
  1455. logger.debug(f"inbox activity={activity}/{data}")
  1456. post_to_inbox(activity)
  1457. return Response(status=201)
  1458. def without_id(l):
  1459. out = []
  1460. for d in l:
  1461. if "_id" in d:
  1462. del d["_id"]
  1463. out.append(d)
  1464. return out
  1465. @app.route("/api/debug", methods=["GET", "DELETE"])
  1466. @api_required
  1467. def api_debug():
  1468. """Endpoint used/needed for testing, only works in DEBUG_MODE."""
  1469. if not DEBUG_MODE:
  1470. return flask_jsonify(message="DEBUG_MODE is off")
  1471. if request.method == "DELETE":
  1472. _drop_db()
  1473. return flask_jsonify(message="DB dropped")
  1474. return flask_jsonify(
  1475. inbox=DB.activities.count({"box": Box.INBOX.value}),
  1476. outbox=DB.activities.count({"box": Box.OUTBOX.value}),
  1477. outbox_data=without_id(DB.activities.find({"box": Box.OUTBOX.value})),
  1478. )
  1479. @app.route("/api/new_note", methods=["POST"])
  1480. @api_required
  1481. def api_new_note():
  1482. source = _user_api_arg("content")
  1483. if not source:
  1484. raise ValueError("missing content")
  1485. _reply, reply = None, None
  1486. try:
  1487. _reply = _user_api_arg("reply")
  1488. except ValueError:
  1489. pass
  1490. content, tags = parse_markdown(source)
  1491. to = request.args.get("to")
  1492. cc = [ID + "/followers"]
  1493. if _reply:
  1494. reply = ap.fetch_remote_activity(_reply)
  1495. cc.append(reply.attributedTo)
  1496. for tag in tags:
  1497. if tag["type"] == "Mention":
  1498. cc.append(tag["href"])
  1499. raw_note = dict(
  1500. attributedTo=MY_PERSON.id,
  1501. cc=list(set(cc)),
  1502. to=[to if to else ap.AS_PUBLIC],
  1503. content=content,
  1504. tag=tags,
  1505. source={"mediaType": "text/markdown", "content": source},
  1506. inReplyTo=reply.id if reply else None,
  1507. )
  1508. if "file" in request.files and request.files["file"].filename:
  1509. file = request.files["file"]
  1510. rfilename = secure_filename(file.filename)
  1511. with BytesIO() as buf:
  1512. file.save(buf)
  1513. oid = MEDIA_CACHE.save_upload(buf, rfilename)
  1514. mtype = mimetypes.guess_type(rfilename)[0]
  1515. raw_note["attachment"] = [
  1516. {
  1517. "mediaType": mtype,
  1518. "name": rfilename,
  1519. "type": "Document",
  1520. "url": f"{BASE_URL}/uploads/{oid}/{rfilename}",
  1521. }
  1522. ]
  1523. note = ap.Note(**raw_note)
  1524. create = note.build_create()
  1525. create_id = post_to_outbox(create)
  1526. return _user_api_response(activity=create_id)
  1527. @app.route("/api/new_question", methods=["POST"])
  1528. @api_required
  1529. def api_new_question():
  1530. source = _user_api_arg("content")
  1531. if not source:
  1532. raise ValueError("missing content")
  1533. content, tags = parse_markdown(source)
  1534. cc = [ID + "/followers"]
  1535. for tag in tags:
  1536. if tag["type"] == "Mention":
  1537. cc.append(tag["href"])
  1538. answers = []
  1539. for i in range(4):
  1540. a = _user_api_arg(f"answer{i}", default=None)
  1541. if not a:
  1542. break
  1543. answers.append({"type": ActivityType.NOTE.value, "name": a})
  1544. choices = {
  1545. "endTime": ap.format_datetime(
  1546. datetime.now().astimezone()
  1547. + timedelta(minutes=int(_user_api_arg("open_for")))
  1548. )
  1549. }
  1550. of = _user_api_arg("of")
  1551. if of == "anyOf":
  1552. choices["anyOf"] = answers
  1553. else:
  1554. choices["oneOf"] = answers
  1555. raw_question = dict(
  1556. attributedTo=MY_PERSON.id,
  1557. cc=list(set(cc)),
  1558. to=[ap.AS_PUBLIC],
  1559. content=content,
  1560. tag=tags,
  1561. source={"mediaType": "text/markdown", "content": source},
  1562. inReplyTo=None,
  1563. **choices,
  1564. )
  1565. question = ap.Question(**raw_question)
  1566. create = question.build_create()
  1567. create_id = post_to_outbox(create)
  1568. return _user_api_response(activity=create_id)
  1569. @app.route("/api/stream")
  1570. @api_required
  1571. def api_stream():
  1572. return Response(
  1573. response=json.dumps(
  1574. activitypub.build_inbox_json_feed("/api/stream", request.args.get("cursor"))
  1575. ),
  1576. headers={"Content-Type": "application/json"},
  1577. )
  1578. @app.route("/api/block", methods=["POST"])
  1579. @api_required
  1580. def api_block():
  1581. actor = _user_api_arg("actor")
  1582. existing = DB.activities.find_one(
  1583. {
  1584. "box": Box.OUTBOX.value,
  1585. "type": ActivityType.BLOCK.value,
  1586. "activity.object": actor,
  1587. "meta.undo": False,
  1588. }
  1589. )
  1590. if existing:
  1591. return _user_api_response(activity=existing["activity"]["id"])
  1592. block = ap.Block(actor=MY_PERSON.id, object=actor)
  1593. block_id = post_to_outbox(block)
  1594. return _user_api_response(activity=block_id)
  1595. @app.route("/api/follow", methods=["POST"])
  1596. @api_required
  1597. def api_follow():
  1598. actor = _user_api_arg("actor")
  1599. q = {
  1600. "box": Box.OUTBOX.value,
  1601. "type": ActivityType.FOLLOW.value,
  1602. "meta.undo": False,
  1603. "activity.object": actor,
  1604. }
  1605. existing = DB.activities.find_one(q)
  1606. if existing:
  1607. return _user_api_response(activity=existing["activity"]["id"])
  1608. follow = ap.Follow(actor=MY_PERSON.id, object=actor)
  1609. follow_id = post_to_outbox(follow)
  1610. return _user_api_response(activity=follow_id)
  1611. @app.route("/followers")
  1612. def followers():
  1613. q = {"box": Box.INBOX.value, "type": ActivityType.FOLLOW.value, "meta.undo": False}
  1614. if is_api_request():
  1615. return jsonify(
  1616. **activitypub.build_ordered_collection(
  1617. DB.activities,
  1618. q=q,
  1619. cursor=request.args.get("cursor"),
  1620. map_func=lambda doc: doc["activity"]["actor"],
  1621. col_name="followers",
  1622. )
  1623. )
  1624. raw_followers, older_than, newer_than = paginated_query(DB.activities, q)
  1625. followers = [
  1626. doc["meta"]["actor"] for doc in raw_followers if "actor" in doc.get("meta", {})
  1627. ]
  1628. return render_template(
  1629. "followers.html",
  1630. followers_data=followers,
  1631. older_than=older_than,
  1632. newer_than=newer_than,
  1633. )
  1634. @app.route("/following")
  1635. def following():
  1636. q = {"box": Box.OUTBOX.value, "type": ActivityType.FOLLOW.value, "meta.undo": False}
  1637. if is_api_request():
  1638. return jsonify(
  1639. **activitypub.build_ordered_collection(
  1640. DB.activities,
  1641. q=q,
  1642. cursor=request.args.get("cursor"),
  1643. map_func=lambda doc: doc["activity"]["object"],
  1644. col_name="following",
  1645. )
  1646. )
  1647. if config.HIDE_FOLLOWING and not session.get("logged_in", False):
  1648. abort(404)
  1649. following, older_than, newer_than = paginated_query(DB.activities, q)
  1650. following = [
  1651. (doc["remote_id"], doc["meta"]["object"])
  1652. for doc in following
  1653. if "remote_id" in doc and "object" in doc.get("meta", {})
  1654. ]
  1655. return render_template(
  1656. "following.html",
  1657. following_data=following,
  1658. older_than=older_than,
  1659. newer_than=newer_than,
  1660. )
  1661. @app.route("/tags/<tag>")
  1662. def tags(tag):
  1663. if not DB.activities.count(
  1664. {
  1665. "box": Box.OUTBOX.value,
  1666. "activity.object.tag.type": "Hashtag",
  1667. "activity.object.tag.name": "#" + tag,
  1668. }
  1669. ):
  1670. abort(404)
  1671. if not is_api_request():
  1672. return render_template(
  1673. "tags.html",
  1674. tag=tag,
  1675. outbox_data=DB.activities.find(
  1676. {
  1677. "box": Box.OUTBOX.value,
  1678. "type": ActivityType.CREATE.value,
  1679. "meta.deleted": False,
  1680. "activity.object.tag.type": "Hashtag",
  1681. "activity.object.tag.name": "#" + tag,
  1682. }
  1683. ),
  1684. )
  1685. q = {
  1686. "box": Box.OUTBOX.value,
  1687. "meta.deleted": False,
  1688. "meta.undo": False,
  1689. "type": ActivityType.CREATE.value,
  1690. "activity.object.tag.type": "Hashtag",
  1691. "activity.object.tag.name": "#" + tag,
  1692. }
  1693. return jsonify(
  1694. **activitypub.build_ordered_collection(
  1695. DB.activities,
  1696. q=q,
  1697. cursor=request.args.get("cursor"),
  1698. map_func=lambda doc: doc["activity"]["object"]["id"],
  1699. col_name=f"tags/{tag}",
  1700. )
  1701. )
  1702. @app.route("/featured")
  1703. def featured():
  1704. if not is_api_request():
  1705. abort(404)
  1706. q = {
  1707. "box": Box.OUTBOX.value,
  1708. "type": ActivityType.CREATE.value,
  1709. "meta.deleted": False,
  1710. "meta.undo": False,
  1711. "meta.pinned": True,
  1712. }
  1713. data = [clean_activity(doc["activity"]["object"]) for doc in DB.activities.find(q)]
  1714. return jsonify(**activitypub.simple_build_ordered_collection("featured", data))
  1715. @app.route("/liked")
  1716. def liked():
  1717. if not is_api_request():
  1718. q = {
  1719. "box": Box.OUTBOX.value,
  1720. "type": ActivityType.LIKE.value,
  1721. "meta.deleted": False,
  1722. "meta.undo": False,
  1723. }
  1724. liked, older_than, newer_than = paginated_query(DB.activities, q)
  1725. return render_template(
  1726. "liked.html", liked=liked, older_than=older_than, newer_than=newer_than
  1727. )
  1728. q = {"meta.deleted": False, "meta.undo": False, "type": ActivityType.LIKE.value}
  1729. return jsonify(
  1730. **activitypub.build_ordered_collection(
  1731. DB.activities,
  1732. q=q,
  1733. cursor=request.args.get("cursor"),
  1734. map_func=lambda doc: doc["activity"]["object"],
  1735. col_name="liked",
  1736. )
  1737. )
  1738. #######
  1739. # IndieAuth
  1740. def build_auth_resp(payload):
  1741. if request.headers.get("Accept") == "application/json":
  1742. return Response(
  1743. status=200,
  1744. headers={"Content-Type": "application/json"},
  1745. response=json.dumps(payload),
  1746. )
  1747. return Response(
  1748. status=200,
  1749. headers={"Content-Type": "application/x-www-form-urlencoded"},
  1750. response=urlencode(payload),
  1751. )
  1752. def _get_prop(props, name, default=None):
  1753. if name in props:
  1754. items = props.get(name)
  1755. if isinstance(items, list):
  1756. return items[0]
  1757. return items
  1758. return default
  1759. def get_client_id_data(url):
  1760. data = mf2py.parse(url=url)
  1761. for item in data["items"]:
  1762. if "h-x-app" in item["type"] or "h-app" in item["type"]:
  1763. props = item.get("properties", {})
  1764. print(props)
  1765. return dict(
  1766. logo=_get_prop(props, "logo"),
  1767. name=_get_prop(props, "name"),
  1768. url=_get_prop(props, "url"),
  1769. )
  1770. return dict(logo=None, name=url, url=url)
  1771. @app.route("/indieauth/flow", methods=["POST"])
  1772. @login_required
  1773. def indieauth_flow():
  1774. auth = dict(
  1775. scope=" ".join(request.form.getlist("scopes")),
  1776. me=request.form.get("me"),
  1777. client_id=request.form.get("client_id"),
  1778. state=request.form.get("state"),
  1779. redirect_uri=request.form.get("redirect_uri"),
  1780. response_type=request.form.get("response_type"),
  1781. )
  1782. code = binascii.hexlify(os.urandom(8)).decode("utf-8")
  1783. auth.update(code=code, verified=False)
  1784. print(auth)
  1785. if not auth["redirect_uri"]:
  1786. abort(500)
  1787. DB.indieauth.insert_one(auth)
  1788. # FIXME(tsileo): fetch client ID and validate redirect_uri
  1789. red = f'{auth["redirect_uri"]}?code={code}&state={auth["state"]}&me={auth["me"]}'
  1790. return redirect(red)
  1791. @app.route("/indieauth", methods=["GET", "POST"])
  1792. def indieauth_endpoint():
  1793. if request.method == "GET":
  1794. if not session.get("logged_in"):
  1795. return redirect(url_for("admin_login", next=request.url))
  1796. me = request.args.get("me")
  1797. # FIXME(tsileo): ensure me == ID
  1798. client_id = request.args.get("client_id")
  1799. redirect_uri = request.args.get("redirect_uri")
  1800. state = request.args.get("state", "")
  1801. response_type = request.args.get("response_type", "id")
  1802. scope = request.args.get("scope", "").split()
  1803. print("STATE", state)
  1804. return render_template(
  1805. "indieauth_flow.html",
  1806. client=get_client_id_data(client_id),
  1807. scopes=scope,
  1808. redirect_uri=redirect_uri,
  1809. state=state,
  1810. response_type=response_type,
  1811. client_id=client_id,
  1812. me=me,
  1813. )
  1814. # Auth verification via POST
  1815. code = request.form.get("code")
  1816. redirect_uri = request.form.get("redirect_uri")
  1817. client_id = request.form.get("client_id")
  1818. auth = DB.indieauth.find_one_and_update(
  1819. {
  1820. "code": code,
  1821. "redirect_uri": redirect_uri,
  1822. "client_id": client_id,
  1823. }, # }, # , 'verified': False},
  1824. {"$set": {"verified": True}},
  1825. sort=[("_id", pymongo.DESCENDING)],
  1826. )
  1827. print(auth)
  1828. print(code, redirect_uri, client_id)
  1829. if not auth:
  1830. abort(403)
  1831. return
  1832. session["logged_in"] = True
  1833. me = auth["me"]
  1834. state = auth["state"]
  1835. scope = " ".join(auth["scope"])
  1836. print("STATE", state)
  1837. return build_auth_resp({"me": me, "state": state, "scope": scope})
  1838. @app.route("/token", methods=["GET", "POST"])
  1839. def token_endpoint():
  1840. if request.method == "POST":
  1841. code = request.form.get("code")
  1842. me = request.form.get("me")
  1843. redirect_uri = request.form.get("redirect_uri")
  1844. client_id = request.form.get("client_id")
  1845. auth = DB.indieauth.find_one(
  1846. {
  1847. "code": code,
  1848. "me": me,
  1849. "redirect_uri": redirect_uri,
  1850. "client_id": client_id,
  1851. }
  1852. )
  1853. if not auth:
  1854. abort(403)
  1855. scope = " ".join(auth["scope"])
  1856. payload = dict(
  1857. me=me, client_id=client_id, scope=scope, ts=datetime.now().timestamp()
  1858. )
  1859. token = JWT.dumps(payload).decode("utf-8")
  1860. return build_auth_resp({"me": me, "scope": scope, "access_token": token})
  1861. # Token verification
  1862. token = request.headers.get("Authorization").replace("Bearer ", "")
  1863. try:
  1864. payload = JWT.loads(token)
  1865. except BadSignature:
  1866. abort(403)
  1867. # TODO(tsileo): handle expiration
  1868. return build_auth_resp(
  1869. {
  1870. "me": payload["me"],
  1871. "scope": payload["scope"],
  1872. "client_id": payload["client_id"],
  1873. }
  1874. )
  1875. #################
  1876. # Feeds
  1877. @app.route("/feed.json")
  1878. def json_feed():
  1879. return Response(
  1880. response=json.dumps(activitypub.json_feed("/feed.json")),
  1881. headers={"Content-Type": "application/json"},
  1882. )
  1883. @app.route("/feed.atom")
  1884. def atom_feed():
  1885. return Response(
  1886. response=activitypub.gen_feed().atom_str(),
  1887. headers={"Content-Type": "application/atom+xml"},
  1888. )
  1889. @app.route("/feed.rss")
  1890. def rss_feed():
  1891. return Response(
  1892. response=activitypub.gen_feed().rss_str(),
  1893. headers={"Content-Type": "application/rss+xml"},
  1894. )
  1895. ###########
  1896. # Tasks
  1897. class Tasks:
  1898. @staticmethod
  1899. def cache_object(iri: str) -> None:
  1900. p.push(iri, "/task/cache_object")
  1901. @staticmethod
  1902. def cache_actor(iri: str, also_cache_attachments: bool = True) -> None:
  1903. p.push(
  1904. {"iri": iri, "also_cache_attachments": also_cache_attachments},
  1905. "/task/cache_actor",
  1906. )
  1907. @staticmethod
  1908. def post_to_remote_inbox(payload: str, recp: str) -> None:
  1909. p.push({"payload": payload, "to": recp}, "/task/post_to_remote_inbox")
  1910. @staticmethod
  1911. def forward_activity(iri: str) -> None:
  1912. p.push(iri, "/task/forward_activity")
  1913. @staticmethod
  1914. def fetch_og_meta(iri: str) -> None:
  1915. p.push(iri, "/task/fetch_og_meta")
  1916. @staticmethod
  1917. def process_new_activity(iri: str) -> None:
  1918. p.push(iri, "/task/process_new_activity")
  1919. @staticmethod
  1920. def cache_attachments(iri: str) -> None:
  1921. p.push(iri, "/task/cache_attachments")
  1922. @staticmethod
  1923. def finish_post_to_inbox(iri: str) -> None:
  1924. p.push(iri, "/task/finish_post_to_inbox")
  1925. @staticmethod
  1926. def finish_post_to_outbox(iri: str) -> None:
  1927. p.push(iri, "/task/finish_post_to_outbox")
  1928. @app.route("/task/fetch_og_meta", methods=["POST"])
  1929. def task_fetch_og_meta():
  1930. task = p.parse(request)
  1931. app.logger.info(f"task={task!r}")
  1932. iri = task.payload
  1933. try:
  1934. activity = ap.fetch_remote_activity(iri)
  1935. app.logger.info(f"activity={activity!r}")
  1936. if activity.has_type(ap.ActivityType.CREATE):
  1937. note = activity.get_object()
  1938. links = opengraph.links_from_note(note.to_dict())
  1939. og_metadata = opengraph.fetch_og_metadata(USER_AGENT, links)
  1940. for og in og_metadata:
  1941. if not og.get("image"):
  1942. continue
  1943. MEDIA_CACHE.cache_og_image2(og["image"], iri)
  1944. app.logger.debug(f"OG metadata {og_metadata!r}")
  1945. DB.activities.update_one(
  1946. {"remote_id": iri}, {"$set": {"meta.og_metadata": og_metadata}}
  1947. )
  1948. app.logger.info(f"OG metadata fetched for {iri}")
  1949. except (ActivityGoneError, ActivityNotFoundError):
  1950. app.logger.exception(f"dropping activity {iri}, skip OG metedata")
  1951. return ""
  1952. except requests.exceptions.HTTPError as http_err:
  1953. if 400 <= http_err.response.status_code < 500:
  1954. app.logger.exception("bad request, no retry")
  1955. return ""
  1956. app.logger.exception("failed to fetch OG metadata")
  1957. raise TaskError() from http_err
  1958. except Exception as err:
  1959. app.logger.exception(f"failed to fetch OG metadata for {iri}")
  1960. raise TaskError() from err
  1961. return ""
  1962. @app.route("/task/cache_object", methods=["POST"])
  1963. def task_cache_object():
  1964. task = p.parse(request)
  1965. app.logger.info(f"task={task!r}")
  1966. iri = task.payload
  1967. try:
  1968. activity = ap.fetch_remote_activity(iri)
  1969. app.logger.info(f"activity={activity!r}")
  1970. obj = activity.get_object()
  1971. DB.activities.update_one(
  1972. {"remote_id": activity.id},
  1973. {
  1974. "$set": {
  1975. "meta.object": obj.to_dict(embed=True),
  1976. "meta.object_actor": activitypub._actor_to_meta(obj.get_actor()),
  1977. }
  1978. },
  1979. )
  1980. except (ActivityGoneError, ActivityNotFoundError, NotAnActivityError):
  1981. DB.activities.update_one({"remote_id": iri}, {"$set": {"meta.deleted": True}})
  1982. app.logger.exception(f"flagging activity {iri} as deleted, no object caching")
  1983. except Exception as err:
  1984. app.logger.exception(f"failed to cache object for {iri}")
  1985. raise TaskError() from err
  1986. return ""
  1987. @app.route("/task/finish_post_to_outbox", methods=["POST"]) # noqa:C901
  1988. def task_finish_post_to_outbox():
  1989. task = p.parse(request)
  1990. app.logger.info(f"task={task!r}")
  1991. iri = task.payload
  1992. try:
  1993. activity = ap.fetch_remote_activity(iri)
  1994. app.logger.info(f"activity={activity!r}")
  1995. recipients = activity.recipients()
  1996. if activity.has_type(ap.ActivityType.DELETE):
  1997. back.outbox_delete(MY_PERSON, activity)
  1998. elif activity.has_type(ap.ActivityType.UPDATE):
  1999. back.outbox_update(MY_PERSON, activity)
  2000. elif activity.has_type(ap.ActivityType.CREATE):
  2001. back.outbox_create(MY_PERSON, activity)
  2002. elif activity.has_type(ap.ActivityType.ANNOUNCE):
  2003. back.outbox_announce(MY_PERSON, activity)
  2004. elif activity.has_type(ap.ActivityType.LIKE):
  2005. back.outbox_like(MY_PERSON, activity)
  2006. elif activity.has_type(ap.ActivityType.UNDO):
  2007. obj = activity.get_object()
  2008. if obj.has_type(ap.ActivityType.LIKE):
  2009. back.outbox_undo_like(MY_PERSON, obj)
  2010. elif obj.has_type(ap.ActivityType.ANNOUNCE):
  2011. back.outbox_undo_announce(MY_PERSON, obj)
  2012. elif obj.has_type(ap.ActivityType.FOLLOW):
  2013. back.undo_new_following(MY_PERSON, obj)
  2014. app.logger.info(f"recipients={recipients}")
  2015. activity = ap.clean_activity(activity.to_dict())
  2016. DB.cache2.remove()
  2017. payload = json.dumps(activity)
  2018. for recp in recipients:
  2019. app.logger.debug(f"posting to {recp}")
  2020. Tasks.post_to_remote_inbox(payload, recp)
  2021. except (ActivityGoneError, ActivityNotFoundError):
  2022. app.logger.exception(f"no retry")
  2023. except Exception as err:
  2024. app.logger.exception(f"failed to post to remote inbox for {iri}")
  2025. raise TaskError() from err
  2026. return ""
  2027. @app.route("/task/finish_post_to_inbox", methods=["POST"]) # noqa: C901
  2028. def task_finish_post_to_inbox():
  2029. task = p.parse(request)
  2030. app.logger.info(f"task={task!r}")
  2031. iri = task.payload
  2032. try:
  2033. activity = ap.fetch_remote_activity(iri)
  2034. app.logger.info(f"activity={activity!r}")
  2035. if activity.has_type(ap.ActivityType.DELETE):
  2036. back.inbox_delete(MY_PERSON, activity)
  2037. elif activity.has_type(ap.ActivityType.UPDATE):
  2038. back.inbox_update(MY_PERSON, activity)
  2039. elif activity.has_type(ap.ActivityType.CREATE):
  2040. back.inbox_create(MY_PERSON, activity)
  2041. elif activity.has_type(ap.ActivityType.ANNOUNCE):
  2042. back.inbox_announce(MY_PERSON, activity)
  2043. elif activity.has_type(ap.ActivityType.LIKE):
  2044. back.inbox_like(MY_PERSON, activity)
  2045. elif activity.has_type(ap.ActivityType.FOLLOW):
  2046. # Reply to a Follow with an Accept
  2047. accept = ap.Accept(actor=ID, object=activity.to_dict(embed=True))
  2048. post_to_outbox(accept)
  2049. elif activity.has_type(ap.ActivityType.UNDO):
  2050. obj = activity.get_object()
  2051. if obj.has_type(ap.ActivityType.LIKE):
  2052. back.inbox_undo_like(MY_PERSON, obj)
  2053. elif obj.has_type(ap.ActivityType.ANNOUNCE):
  2054. back.inbox_undo_announce(MY_PERSON, obj)
  2055. elif obj.has_type(ap.ActivityType.FOLLOW):
  2056. back.undo_new_follower(MY_PERSON, obj)
  2057. try:
  2058. invalidate_cache(activity)
  2059. except Exception:
  2060. app.logger.exception("failed to invalidate cache")
  2061. except (ActivityGoneError, ActivityNotFoundError, NotAnActivityError):
  2062. app.logger.exception(f"no retry")
  2063. except Exception as err:
  2064. app.logger.exception(f"failed to cache attachments for {iri}")
  2065. raise TaskError() from err
  2066. return ""
  2067. def post_to_outbox(activity: ap.BaseActivity) -> str:
  2068. if activity.has_type(ap.CREATE_TYPES):
  2069. activity = activity.build_create()
  2070. # Assign create a random ID
  2071. obj_id = back.random_object_id()
  2072. activity.set_id(back.activity_url(obj_id), obj_id)
  2073. back.save(Box.OUTBOX, activity)
  2074. Tasks.cache_actor(activity.id)
  2075. Tasks.finish_post_to_outbox(activity.id)
  2076. return activity.id
  2077. def post_to_inbox(activity: ap.BaseActivity) -> None:
  2078. # Check for Block activity
  2079. actor = activity.get_actor()
  2080. if back.outbox_is_blocked(MY_PERSON, actor.id):
  2081. app.logger.info(
  2082. f"actor {actor!r} is blocked, dropping the received activity {activity!r}"
  2083. )
  2084. return
  2085. if back.inbox_check_duplicate(MY_PERSON, activity.id):
  2086. # The activity is already in the inbox
  2087. app.logger.info(f"received duplicate activity {activity!r}, dropping it")
  2088. back.save(Box.INBOX, activity)
  2089. Tasks.process_new_activity(activity.id)
  2090. app.logger.info(f"spawning task for {activity!r}")
  2091. Tasks.finish_post_to_inbox(activity.id)
  2092. def invalidate_cache(activity):
  2093. if activity.has_type(ap.ActivityType.LIKE):
  2094. if activity.get_object().id.startswith(BASE_URL):
  2095. DB.cache2.remove()
  2096. elif activity.has_type(ap.ActivityType.ANNOUNCE):
  2097. if activity.get_object().id.startswith(BASE_URL):
  2098. DB.cache2.remove()
  2099. elif activity.has_type(ap.ActivityType.UNDO):
  2100. DB.cache2.remove()
  2101. elif activity.has_type(ap.ActivityType.DELETE):
  2102. # TODO(tsileo): only invalidate if it's a delete of a reply
  2103. DB.cache2.remove()
  2104. elif activity.has_type(ap.ActivityType.UPDATE):
  2105. DB.cache2.remove()
  2106. elif activity.has_type(ap.ActivityType.CREATE):
  2107. note = activity.get_object()
  2108. in_reply_to = note.get_in_reply_to()
  2109. if not in_reply_to or in_reply_to.startswith(ID):
  2110. DB.cache2.remove()
  2111. # FIXME(tsileo): check if it's a reply of a reply
  2112. @app.route("/task/cache_attachments", methods=["POST"])
  2113. def task_cache_attachments():
  2114. task = p.parse(request)
  2115. app.logger.info(f"task={task!r}")
  2116. iri = task.payload
  2117. try:
  2118. activity = ap.fetch_remote_activity(iri)
  2119. app.logger.info(f"activity={activity!r}")
  2120. # Generates thumbnails for the actor's icon and the attachments if any
  2121. actor = activity.get_actor()
  2122. # Update the cached actor
  2123. DB.actors.update_one(
  2124. {"remote_id": iri},
  2125. {"$set": {"remote_id": iri, "data": actor.to_dict(embed=True)}},
  2126. upsert=True,
  2127. )
  2128. if actor.icon:
  2129. MEDIA_CACHE.cache(actor.icon["url"], Kind.ACTOR_ICON)
  2130. if activity.has_type(ap.ActivityType.CREATE):
  2131. for attachment in activity.get_object()._data.get("attachment", []):
  2132. if (
  2133. attachment.get("mediaType", "").startswith("image/")
  2134. or attachment.get("type") == ap.ActivityType.IMAGE.value
  2135. ):
  2136. try:
  2137. MEDIA_CACHE.cache_attachment2(attachment["url"], iri)
  2138. except ValueError:
  2139. app.logger.exception(f"failed to cache {attachment}")
  2140. app.logger.info(f"attachments cached for {iri}")
  2141. except (ActivityGoneError, ActivityNotFoundError, NotAnActivityError):
  2142. app.logger.exception(f"dropping activity {iri}, no attachment caching")
  2143. except Exception as err:
  2144. app.logger.exception(f"failed to cache attachments for {iri}")
  2145. raise TaskError() from err
  2146. return ""
  2147. @app.route("/task/cache_actor", methods=["POST"])
  2148. def task_cache_actor() -> str:
  2149. task = p.parse(request)
  2150. app.logger.info(f"task={task!r}")
  2151. iri, also_cache_attachments = (
  2152. task.payload["iri"],
  2153. task.payload.get("also_cache_attachments", True),
  2154. )
  2155. try:
  2156. activity = ap.fetch_remote_activity(iri)
  2157. app.logger.info(f"activity={activity!r}")
  2158. if activity.has_type(ap.ActivityType.CREATE):
  2159. Tasks.fetch_og_meta(iri)
  2160. if activity.has_type([ap.ActivityType.LIKE, ap.ActivityType.ANNOUNCE]):
  2161. Tasks.cache_object(iri)
  2162. actor = activity.get_actor()
  2163. cache_actor_with_inbox = False
  2164. if activity.has_type(ap.ActivityType.FOLLOW):
  2165. if actor.id != ID:
  2166. # It's a Follow from the Inbox
  2167. cache_actor_with_inbox = True
  2168. else:
  2169. # It's a new following, cache the "object" (which is the actor we follow)
  2170. DB.activities.update_one(
  2171. {"remote_id": iri},
  2172. {
  2173. "$set": {
  2174. "meta.object": activitypub._actor_to_meta(
  2175. activity.get_object()
  2176. )
  2177. }
  2178. },
  2179. )
  2180. # Cache the actor info
  2181. DB.activities.update_one(
  2182. {"remote_id": iri},
  2183. {
  2184. "$set": {
  2185. "meta.actor": activitypub._actor_to_meta(
  2186. actor, cache_actor_with_inbox
  2187. )
  2188. }
  2189. },
  2190. )
  2191. app.logger.info(f"actor cached for {iri}")
  2192. if also_cache_attachments and activity.has_type(ap.ActivityType.CREATE):
  2193. Tasks.cache_attachments(iri)
  2194. except (ActivityGoneError, ActivityNotFoundError):
  2195. DB.activities.update_one({"remote_id": iri}, {"$set": {"meta.deleted": True}})
  2196. app.logger.exception(f"flagging activity {iri} as deleted, no actor caching")
  2197. except Exception as err:
  2198. app.logger.exception(f"failed to cache actor for {iri}")
  2199. raise TaskError() from err
  2200. return ""
  2201. @app.route("/task/process_new_activity", methods=["POST"]) # noqa:c901
  2202. def task_process_new_activity():
  2203. """Process an activity received in the inbox"""
  2204. task = p.parse(request)
  2205. app.logger.info(f"task={task!r}")
  2206. iri = task.payload
  2207. try:
  2208. activity = ap.fetch_remote_activity(iri)
  2209. app.logger.info(f"activity={activity!r}")
  2210. # Is the activity expected?
  2211. # following = ap.get_backend().following()
  2212. should_forward = False
  2213. should_delete = False
  2214. should_keep = False
  2215. tag_stream = False
  2216. if activity.has_type(ap.ActivityType.ANNOUNCE):
  2217. # FIXME(tsileo): Ensure it's follower and store into a "dead activities" DB
  2218. try:
  2219. activity.get_object()
  2220. tag_stream = True
  2221. if activity.get_object_id().startswith(BASE_URL):
  2222. should_keep = True
  2223. except (NotAnActivityError, BadActivityError):
  2224. app.logger.exception(f"failed to get announce object for {activity!r}")
  2225. # Most likely on OStatus notice
  2226. tag_stream = False
  2227. should_delete = True
  2228. except (ActivityGoneError, ActivityNotFoundError):
  2229. # The announced activity is deleted/gone, drop it
  2230. should_delete = True
  2231. elif activity.has_type(ap.ActivityType.FOLLOW):
  2232. # FIXME(tsileo): ensure it's a follow where the server is the object
  2233. should_keep = True
  2234. elif activity.has_type(ap.ActivityType.CREATE):
  2235. note = activity.get_object()
  2236. in_reply_to = note.get_in_reply_to()
  2237. # Make the note part of the stream if it's not a reply, or if it's a local reply
  2238. if not in_reply_to or in_reply_to.startswith(ID):
  2239. tag_stream = True
  2240. # FIXME(tsileo): check for direct addressing in the to, cc, bcc... fields
  2241. if (in_reply_to and in_reply_to.startswith(ID)) or note.has_mention(ID):
  2242. should_keep = True
  2243. if in_reply_to:
  2244. try:
  2245. reply = ap.fetch_remote_activity(note.get_in_reply_to())
  2246. if (
  2247. reply.id.startswith(ID) or reply.has_mention(ID)
  2248. ) and activity.is_public():
  2249. # The reply is public "local reply", forward the reply (i.e. the original activity) to the
  2250. # original recipients
  2251. should_forward = True
  2252. should_keep = True
  2253. except NotAnActivityError:
  2254. # Most likely a reply to an OStatus notce
  2255. should_delete = True
  2256. # (partial) Ghost replies handling
  2257. # [X] This is the first time the server has seen this Activity.
  2258. should_forward = False
  2259. local_followers = ID + "/followers"
  2260. for field in ["to", "cc"]:
  2261. if field in activity._data:
  2262. if local_followers in activity._data[field]:
  2263. # [X] The values of to, cc, and/or audience contain a Collection owned by the server.
  2264. should_forward = True
  2265. # [X] The values of inReplyTo, object, target and/or tag are objects owned by the server
  2266. if not (in_reply_to and in_reply_to.startswith(ID)):
  2267. should_forward = False
  2268. elif activity.has_type(ap.ActivityType.DELETE):
  2269. note = DB.activities.find_one(
  2270. {"activity.object.id": activity.get_object().id}
  2271. )
  2272. if note and note["meta"].get("forwarded", False):
  2273. # If the activity was originally forwarded, forward the delete too
  2274. should_forward = True
  2275. elif activity.has_type(ap.ActivityType.LIKE):
  2276. if activity.get_object_id().startswith(BASE_URL):
  2277. should_keep = True
  2278. else:
  2279. # We only want to keep a like if it's a like for a local activity
  2280. # (Pleroma relay the likes it received, we don't want to store them)
  2281. should_delete = True
  2282. if should_forward:
  2283. app.logger.info(f"will forward {activity!r} to followers")
  2284. Tasks.forward_activity(activity.id)
  2285. if should_delete:
  2286. app.logger.info(f"will soft delete {activity!r}")
  2287. app.logger.info(f"{iri} tag_stream={tag_stream}")
  2288. DB.activities.update_one(
  2289. {"remote_id": activity.id},
  2290. {
  2291. "$set": {
  2292. "meta.keep": should_keep,
  2293. "meta.stream": tag_stream,
  2294. "meta.forwarded": should_forward,
  2295. "meta.deleted": should_delete,
  2296. }
  2297. },
  2298. )
  2299. app.logger.info(f"new activity {iri} processed")
  2300. if not should_delete and not activity.has_type(ap.ActivityType.DELETE):
  2301. Tasks.cache_actor(iri)
  2302. except (ActivityGoneError, ActivityNotFoundError):
  2303. app.logger.exception(f"dropping activity {iri}, skip processing")
  2304. return ""
  2305. except Exception as err:
  2306. app.logger.exception(f"failed to process new activity {iri}")
  2307. raise TaskError() from err
  2308. return ""
  2309. @app.route("/task/forward_activity", methods=["POST"])
  2310. def task_forward_activity():
  2311. task = p.parse(request)
  2312. app.logger.info(f"task={task!r}")
  2313. iri = task.payload
  2314. try:
  2315. activity = ap.fetch_remote_activity(iri)
  2316. recipients = back.followers_as_recipients()
  2317. app.logger.debug(f"Forwarding {activity!r} to {recipients}")
  2318. activity = ap.clean_activity(activity.to_dict())
  2319. payload = json.dumps(activity)
  2320. for recp in recipients:
  2321. app.logger.debug(f"forwarding {activity!r} to {recp}")
  2322. Tasks.post_to_remote_inbox(payload, recp)
  2323. except Exception as err:
  2324. app.logger.exception("task failed")
  2325. raise TaskError() from err
  2326. return ""
  2327. @app.route("/task/post_to_remote_inbox", methods=["POST"])
  2328. def task_post_to_remote_inbox():
  2329. """Post an activity to a remote inbox."""
  2330. task = p.parse(request)
  2331. app.logger.info(f"task={task!r}")
  2332. payload, to = task.payload["payload"], task.payload["to"]
  2333. try:
  2334. app.logger.info("payload=%s", payload)
  2335. app.logger.info("generating sig")
  2336. signed_payload = json.loads(payload)
  2337. # Don't overwrite the signature if we're forwarding an activity
  2338. if "signature" not in signed_payload:
  2339. generate_signature(signed_payload, KEY)
  2340. app.logger.info("to=%s", to)
  2341. resp = requests.post(
  2342. to,
  2343. data=json.dumps(signed_payload),
  2344. auth=SIG_AUTH,
  2345. headers={
  2346. "Content-Type": HEADERS[1],
  2347. "Accept": HEADERS[1],
  2348. "User-Agent": USER_AGENT,
  2349. },
  2350. )
  2351. app.logger.info("resp=%s", resp)
  2352. app.logger.info("resp_body=%s", resp.text)
  2353. resp.raise_for_status()
  2354. except HTTPError as err:
  2355. app.logger.exception("request failed")
  2356. if 400 >= err.response.status_code >= 499:
  2357. app.logger.info("client error, no retry")
  2358. return ""
  2359. raise TaskError() from err
  2360. except Exception as err:
  2361. app.logger.exception("task failed")
  2362. raise TaskError() from err
  2363. return ""
  2364. @app.route("/task/update_question", methods=["POST"])
  2365. def task_update_question():
  2366. """Post an activity to a remote inbox."""
  2367. task = p.parse(request)
  2368. app.logger.info(f"task={task!r}")
  2369. iri = task.payload
  2370. try:
  2371. app.logger.info(f"Updating question {iri}")
  2372. # TODO(tsileo): sends an Update with the question/iri as an actor, with the updated stats (LD sig will fail?)
  2373. # but to who? followers and people who voted? but this must not be visible right?
  2374. # also sends/trigger a notification when a poll I voted for ends like Mastodon?
  2375. except HTTPError as err:
  2376. app.logger.exception("request failed")
  2377. if 400 >= err.response.status_code >= 499:
  2378. app.logger.info("client error, no retry")
  2379. return ""
  2380. raise TaskError() from err
  2381. except Exception as err:
  2382. app.logger.exception("task failed")
  2383. raise TaskError() from err
  2384. return ""
  2385. @app.route("/task/cleanup", methods=["POST"])
  2386. def task_cleanup():
  2387. task = p.parse(request)
  2388. app.logger.info(f"task={task!r}")
  2389. p.push({}, "/task/cleanup_part_1")
  2390. return ""
  2391. @app.route("/task/cleanup_part_1", methods=["POST"])
  2392. def task_cleanup_part_1():
  2393. task = p.parse(request)
  2394. app.logger.info(f"task={task!r}")
  2395. d = (datetime.utcnow() - timedelta(days=15)).strftime("%Y-%m-%d")
  2396. # (We keep Follow and Accept forever)
  2397. # Announce and Like cleanup
  2398. for ap_type in [ActivityType.ANNOUNCE, ActivityType.LIKE]:
  2399. # Migrate old (before meta.keep activities on the fly)
  2400. DB.activities.update_many(
  2401. {
  2402. "box": Box.INBOX.value,
  2403. "type": ap_type.value,
  2404. "meta.keep": {"$exists": False},
  2405. "activity.object": {"$regex": f"^{BASE_URL}"},
  2406. },
  2407. {"$set": {"meta.keep": True}},
  2408. )
  2409. DB.activities.update_many(
  2410. {
  2411. "box": Box.INBOX.value,
  2412. "type": ap_type.value,
  2413. "meta.keep": {"$exists": False},
  2414. "activity.object.id": {"$regex": f"^{BASE_URL}"},
  2415. },
  2416. {"$set": {"meta.keep": True}},
  2417. )
  2418. DB.activities.update_many(
  2419. {
  2420. "box": Box.INBOX.value,
  2421. "type": ap_type.value,
  2422. "meta.keep": {"$exists": False},
  2423. },
  2424. {"$set": {"meta.keep": False}},
  2425. )
  2426. # End of the migration
  2427. # Delete old activities
  2428. DB.activities.delete_many(
  2429. {
  2430. "box": Box.INBOX.value,
  2431. "type": ap_type.value,
  2432. "meta.keep": False,
  2433. "activity.published": {"$lt": d},
  2434. }
  2435. )
  2436. # And delete the soft-deleted one
  2437. DB.activities.delete_many(
  2438. {
  2439. "box": Box.INBOX.value,
  2440. "type": ap_type.value,
  2441. "meta.keep": False,
  2442. "meta.deleted": True,
  2443. }
  2444. )
  2445. # Create cleanup (more complicated)
  2446. # The one that mention our actor
  2447. DB.activities.update_many(
  2448. {
  2449. "box": Box.INBOX.value,
  2450. "meta.keep": {"$exists": False},
  2451. "activity.object.tag.href": {"$regex": f"^{BASE_URL}"},
  2452. },
  2453. {"$set": {"meta.keep": True}},
  2454. )
  2455. DB.activities.update_many(
  2456. {
  2457. "box": Box.REPLIES.value,
  2458. "meta.keep": {"$exists": False},
  2459. "activity.tag.href": {"$regex": f"^{BASE_URL}"},
  2460. },
  2461. {"$set": {"meta.keep": True}},
  2462. )
  2463. # The replies of the outbox
  2464. DB.activities.update_many(
  2465. {"meta.thread_root_parent": {"$regex": f"^{BASE_URL}"}},
  2466. {"$set": {"meta.keep": True}},
  2467. )
  2468. # Track all the threads we participated
  2469. keep_threads = []
  2470. for data in DB.activities.find(
  2471. {
  2472. "box": Box.OUTBOX.value,
  2473. "type": ActivityType.CREATE.value,
  2474. "meta.thread_root_parent": {"$exists": True},
  2475. }
  2476. ):
  2477. keep_threads.append(data["meta"]["thread_root_parent"])
  2478. for root_parent in set(keep_threads):
  2479. DB.activities.update_many(
  2480. {"meta.thread_root_parent": root_parent}, {"$set": {"meta.keep": True}}
  2481. )
  2482. DB.activities.update_many(
  2483. {
  2484. "box": {"$in": [Box.REPLIES.value, Box.INBOX.value]},
  2485. "meta.keep": {"$exists": False},
  2486. },
  2487. {"$set": {"meta.keep": False}},
  2488. )
  2489. DB.activities.update_many(
  2490. {
  2491. "box": Box.OUTBOX.value,
  2492. "type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
  2493. "meta.public": {"$exists": False},
  2494. },
  2495. {"$set": {"meta.public": True}},
  2496. )
  2497. p.push({}, "/task/cleanup_part_2")
  2498. return "OK"
  2499. @app.route("/task/cleanup_part_2", methods=["POST"])
  2500. def task_cleanup_part_2():
  2501. task = p.parse(request)
  2502. app.logger.info(f"task={task!r}")
  2503. d = (datetime.utcnow() - timedelta(days=15)).strftime("%Y-%m-%d")
  2504. # Go over the old Create activities
  2505. for data in DB.activities.find(
  2506. {
  2507. "box": Box.INBOX.value,
  2508. "type": ActivityType.CREATE.value,
  2509. "meta.keep": False,
  2510. "activity.published": {"$lt": d},
  2511. }
  2512. ).limit(5000):
  2513. # Delete the cached attachment/
  2514. for grid_item in MEDIA_CACHE.fs.find({"remote_id": data["remote_id"]}):
  2515. MEDIA_CACHE.fs.delete(grid_item._id)
  2516. DB.activities.delete_one({"_id": data["_id"]})
  2517. p.push({}, "/task/cleanup_part_3")
  2518. return "OK"
  2519. @app.route("/task/cleanup_part_3", methods=["POST"])
  2520. def task_cleanup_part_3():
  2521. task = p.parse(request)
  2522. app.logger.info(f"task={task!r}")
  2523. d = (datetime.utcnow() - timedelta(days=15)).strftime("%Y-%m-%d")
  2524. # Delete old replies we don't care about
  2525. DB.activities.delete_many(
  2526. {"box": Box.REPLIES.value, "meta.keep": False, "activity.published": {"$lt": d}}
  2527. )
  2528. # Remove all the attachments no tied to a remote_id (post celery migration)
  2529. for grid_item in MEDIA_CACHE.fs.find(
  2530. {"kind": {"$in": ["og", "attachment"]}, "remote_id": {"$exists": False}}
  2531. ):
  2532. MEDIA_CACHE.fs.delete(grid_item._id)
  2533. # TODO(tsileo): iterator over "actor_icon" and look for unused one in a separate task
  2534. return "OK"