announce.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. __filename__ = "announce.py"
  2. __author__ = "Bob Mottram"
  3. __license__ = "AGPL3+"
  4. __version__ = "1.1.0"
  5. __maintainer__ = "Bob Mottram"
  6. __email__ = "bob@freedombone.net"
  7. __status__ = "Production"
  8. from utils import getStatusNumber
  9. from utils import createOutboxDir
  10. from utils import urlPermitted
  11. from utils import getNicknameFromActor
  12. from utils import getDomainFromActor
  13. from utils import locatePost
  14. from utils import saveJson
  15. from utils import undoAnnounceCollectionEntry
  16. from utils import updateAnnounceCollection
  17. from posts import sendSignedJson
  18. from posts import getPersonBox
  19. from session import postJson
  20. from webfinger import webfingerHandle
  21. from auth import createBasicAuthHeader
  22. def outboxAnnounce(recentPostsCache: {},
  23. baseDir: str, messageJson: {}, debug: bool) -> bool:
  24. """ Adds or removes announce entries from the shares collection
  25. within a given post
  26. """
  27. if not messageJson.get('actor'):
  28. return False
  29. if not messageJson.get('type'):
  30. return False
  31. if not messageJson.get('object'):
  32. return False
  33. if messageJson['type'] == 'Announce':
  34. if not isinstance(messageJson['object'], str):
  35. return False
  36. nickname = getNicknameFromActor(messageJson['actor'])
  37. if not nickname:
  38. print('WARN: no nickname found in '+messageJson['actor'])
  39. return False
  40. domain, port = getDomainFromActor(messageJson['actor'])
  41. postFilename = locatePost(baseDir, nickname, domain,
  42. messageJson['object'])
  43. if postFilename:
  44. updateAnnounceCollection(recentPostsCache, baseDir, postFilename,
  45. messageJson['actor'], domain, debug)
  46. return True
  47. if messageJson['type'] == 'Undo':
  48. if not isinstance(messageJson['object'], dict):
  49. return False
  50. if not messageJson['object'].get('type'):
  51. return False
  52. if messageJson['object']['type'] == 'Announce':
  53. if not isinstance(messageJson['object']['object'], str):
  54. return False
  55. nickname = getNicknameFromActor(messageJson['actor'])
  56. if not nickname:
  57. print('WARN: no nickname found in ' + messageJson['actor'])
  58. return False
  59. domain, port = getDomainFromActor(messageJson['actor'])
  60. postFilename = locatePost(baseDir, nickname, domain,
  61. messageJson['object']['object'])
  62. if postFilename:
  63. undoAnnounceCollectionEntry(recentPostsCache,
  64. baseDir, postFilename,
  65. messageJson['actor'],
  66. domain, debug)
  67. return True
  68. return False
  69. def announcedByPerson(postJsonObject: {}, nickname: str, domain: str) -> bool:
  70. """Returns True if the given post is announced by the given person
  71. """
  72. if not postJsonObject.get('object'):
  73. return False
  74. if not isinstance(postJsonObject['object'], dict):
  75. return False
  76. # not to be confused with shared items
  77. if not postJsonObject['object'].get('shares'):
  78. return False
  79. actorMatch = domain + '/users/' + nickname
  80. for item in postJsonObject['object']['shares']['items']:
  81. if item['actor'].endswith(actorMatch):
  82. return True
  83. return False
  84. def createAnnounce(session, baseDir: str, federationList: [],
  85. nickname: str, domain: str, port: int,
  86. toUrl: str, ccUrl: str, httpPrefix: str,
  87. objectUrl: str, saveToFile: bool,
  88. clientToServer: bool,
  89. sendThreads: [], postLog: [],
  90. personCache: {}, cachedWebfingers: {},
  91. debug: bool, projectVersion: str) -> {}:
  92. """Creates an announce message
  93. Typically toUrl will be https://www.w3.org/ns/activitystreams#Public
  94. and ccUrl might be a specific person favorited or repeated and the
  95. followers url objectUrl is typically the url of the message,
  96. corresponding to url or atomUri in createPostBase
  97. """
  98. if not urlPermitted(objectUrl, federationList, "inbox:write"):
  99. return None
  100. if ':' in domain:
  101. domain = domain.split(':')[0]
  102. fullDomain = domain
  103. if port:
  104. if port != 80 and port != 443:
  105. if ':' not in domain:
  106. fullDomain = domain + ':' + str(port)
  107. statusNumber, published = getStatusNumber()
  108. newAnnounceId = httpPrefix + '://' + fullDomain + \
  109. '/users/' + nickname + '/statuses/' + statusNumber
  110. atomUriStr = httpPrefix + '://' + fullDomain + '/users/' + nickname + \
  111. '/statuses/' + statusNumber
  112. newAnnounce = {
  113. "@context": "https://www.w3.org/ns/activitystreams",
  114. 'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname,
  115. 'atomUri': atomUriStr,
  116. 'cc': [],
  117. 'id': newAnnounceId+'/activity',
  118. 'object': objectUrl,
  119. 'published': published,
  120. 'to': [toUrl],
  121. 'type': 'Announce'
  122. }
  123. if ccUrl:
  124. if len(ccUrl) > 0:
  125. newAnnounce['cc'] = [ccUrl]
  126. if saveToFile:
  127. outboxDir = createOutboxDir(nickname, domain, baseDir)
  128. filename = outboxDir + '/' + newAnnounceId.replace('/', '#') + '.json'
  129. saveJson(newAnnounce, filename)
  130. announceNickname = None
  131. announceDomain = None
  132. announcePort = None
  133. if '/users/' in objectUrl or \
  134. '/channel/' in objectUrl or \
  135. '/profile/' in objectUrl:
  136. announceNickname = getNicknameFromActor(objectUrl)
  137. announceDomain, announcePort = getDomainFromActor(objectUrl)
  138. if announceNickname and announceDomain:
  139. sendSignedJson(newAnnounce, session, baseDir,
  140. nickname, domain, port,
  141. announceNickname, announceDomain, announcePort, None,
  142. httpPrefix, True, clientToServer, federationList,
  143. sendThreads, postLog, cachedWebfingers, personCache,
  144. debug, projectVersion)
  145. return newAnnounce
  146. def announcePublic(session, baseDir: str, federationList: [],
  147. nickname: str, domain: str, port: int, httpPrefix: str,
  148. objectUrl: str, clientToServer: bool,
  149. sendThreads: [], postLog: [],
  150. personCache: {}, cachedWebfingers: {},
  151. debug: bool, projectVersion: str) -> {}:
  152. """Makes a public announcement
  153. """
  154. fromDomain = domain
  155. if port:
  156. if port != 80 and port != 443:
  157. if ':' not in domain:
  158. fromDomain = domain + ':' + str(port)
  159. toUrl = 'https://www.w3.org/ns/activitystreams#Public'
  160. ccUrl = httpPrefix + '://' + fromDomain + '/users/' + nickname + \
  161. '/followers'
  162. return createAnnounce(session, baseDir, federationList,
  163. nickname, domain, port,
  164. toUrl, ccUrl, httpPrefix,
  165. objectUrl, True, clientToServer,
  166. sendThreads, postLog,
  167. personCache, cachedWebfingers,
  168. debug, projectVersion)
  169. def repeatPost(session, baseDir: str, federationList: [],
  170. nickname: str, domain: str, port: int, httpPrefix: str,
  171. announceNickname: str, announceDomain: str,
  172. announcePort: int, announceHttpsPrefix: str,
  173. announceStatusNumber: int, clientToServer: bool,
  174. sendThreads: [], postLog: [],
  175. personCache: {}, cachedWebfingers: {},
  176. debug: bool, projectVersion: str) -> {}:
  177. """Repeats a given status post
  178. """
  179. announcedDomain = announceDomain
  180. if announcePort:
  181. if announcePort != 80 and announcePort != 443:
  182. if ':' not in announcedDomain:
  183. announcedDomain = announcedDomain + ':' + str(announcePort)
  184. objectUrl = announceHttpsPrefix + '://' + announcedDomain + '/users/' + \
  185. announceNickname + '/statuses/' + str(announceStatusNumber)
  186. return announcePublic(session, baseDir, federationList,
  187. nickname, domain, port, httpPrefix,
  188. objectUrl, clientToServer,
  189. sendThreads, postLog,
  190. personCache, cachedWebfingers,
  191. debug, projectVersion)
  192. def undoAnnounce(session, baseDir: str, federationList: [],
  193. nickname: str, domain: str, port: int,
  194. toUrl: str, ccUrl: str, httpPrefix: str,
  195. objectUrl: str, saveToFile: bool,
  196. clientToServer: bool,
  197. sendThreads: [], postLog: [],
  198. personCache: {}, cachedWebfingers: {},
  199. debug: bool) -> {}:
  200. """Undoes an announce message
  201. Typically toUrl will be https://www.w3.org/ns/activitystreams#Public
  202. and ccUrl might be a specific person whose post was repeated and the
  203. objectUrl is typically the url of the message which was repeated,
  204. corresponding to url or atomUri in createPostBase
  205. """
  206. if not urlPermitted(objectUrl, federationList, "inbox:write"):
  207. return None
  208. if ':' in domain:
  209. domain = domain.split(':')[0]
  210. fullDomain = domain
  211. if port:
  212. if port != 80 and port != 443:
  213. if ':' not in domain:
  214. fullDomain = domain + ':' + str(port)
  215. newUndoAnnounce = {
  216. "@context": "https://www.w3.org/ns/activitystreams",
  217. 'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname,
  218. 'type': 'Undo',
  219. 'cc': [],
  220. 'to': [toUrl],
  221. 'object': {
  222. 'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname,
  223. 'cc': [],
  224. 'object': objectUrl,
  225. 'to': [toUrl],
  226. 'type': 'Announce'
  227. }
  228. }
  229. if ccUrl:
  230. if len(ccUrl) > 0:
  231. newUndoAnnounce['object']['cc'] = [ccUrl]
  232. announceNickname = None
  233. announceDomain = None
  234. announcePort = None
  235. if '/users/' in objectUrl or \
  236. '/channel/' in objectUrl or \
  237. '/profile/' in objectUrl:
  238. announceNickname = getNicknameFromActor(objectUrl)
  239. announceDomain, announcePort = getDomainFromActor(objectUrl)
  240. if announceNickname and announceDomain:
  241. sendSignedJson(newUndoAnnounce, session, baseDir,
  242. nickname, domain, port,
  243. announceNickname, announceDomain, announcePort,
  244. 'https://www.w3.org/ns/activitystreams#Public',
  245. httpPrefix, True, clientToServer, federationList,
  246. sendThreads, postLog, cachedWebfingers,
  247. personCache, debug)
  248. return newUndoAnnounce
  249. def undoAnnouncePublic(session, baseDir: str, federationList: [],
  250. nickname: str, domain: str, port: int, httpPrefix: str,
  251. objectUrl: str, clientToServer: bool,
  252. sendThreads: [], postLog: [],
  253. personCache: {}, cachedWebfingers: {},
  254. debug: bool) -> {}:
  255. """Undoes a public announcement
  256. """
  257. fromDomain = domain
  258. if port:
  259. if port != 80 and port != 443:
  260. if ':' not in domain:
  261. fromDomain = domain + ':' + str(port)
  262. toUrl = 'https://www.w3.org/ns/activitystreams#Public'
  263. ccUrl = httpPrefix + '://' + fromDomain + '/users/' + nickname + \
  264. '/followers'
  265. return undoAnnounce(session, baseDir, federationList,
  266. nickname, domain, port,
  267. toUrl, ccUrl, httpPrefix,
  268. objectUrl, True, clientToServer,
  269. sendThreads, postLog,
  270. personCache, cachedWebfingers,
  271. debug)
  272. def undoRepeatPost(session, baseDir: str, federationList: [],
  273. nickname: str, domain: str, port: int, httpPrefix: str,
  274. announceNickname: str, announceDomain: str,
  275. announcePort: int, announceHttpsPrefix: str,
  276. announceStatusNumber: int, clientToServer: bool,
  277. sendThreads: [], postLog: [],
  278. personCache: {}, cachedWebfingers: {},
  279. debug: bool) -> {}:
  280. """Undoes a status post repeat
  281. """
  282. announcedDomain = announceDomain
  283. if announcePort:
  284. if announcePort != 80 and announcePort != 443:
  285. if ':' not in announcedDomain:
  286. announcedDomain = announcedDomain + ':' + str(announcePort)
  287. objectUrl = announceHttpsPrefix + '://' + announcedDomain + '/users/' + \
  288. announceNickname + '/statuses/' + str(announceStatusNumber)
  289. return undoAnnouncePublic(session, baseDir, federationList,
  290. nickname, domain, port, httpPrefix,
  291. objectUrl, clientToServer,
  292. sendThreads, postLog,
  293. personCache, cachedWebfingers,
  294. debug)
  295. def sendAnnounceViaServer(baseDir: str, session,
  296. fromNickname: str, password: str,
  297. fromDomain: str, fromPort: int,
  298. httpPrefix: str, repeatObjectUrl: str,
  299. cachedWebfingers: {}, personCache: {},
  300. debug: bool, projectVersion: str) -> {}:
  301. """Creates an announce message via c2s
  302. """
  303. if not session:
  304. print('WARN: No session for sendAnnounceViaServer')
  305. return 6
  306. fromDomainFull = fromDomain
  307. if fromPort:
  308. if fromPort != 80 and fromPort != 443:
  309. if ':' not in fromDomain:
  310. fromDomainFull = fromDomain + ':' + str(fromPort)
  311. toUrl = 'https://www.w3.org/ns/activitystreams#Public'
  312. ccUrl = httpPrefix + '://' + fromDomainFull + '/users/' + fromNickname + \
  313. '/followers'
  314. statusNumber, published = getStatusNumber()
  315. newAnnounceId = httpPrefix + '://' + fromDomainFull + '/users/' + \
  316. fromNickname + '/statuses/' + statusNumber
  317. newAnnounceJson = {
  318. "@context": "https://www.w3.org/ns/activitystreams",
  319. 'actor': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname,
  320. 'atomUri': newAnnounceId,
  321. 'cc': [ccUrl],
  322. 'id': newAnnounceId+'/activity',
  323. 'object': repeatObjectUrl,
  324. 'published': published,
  325. 'to': [toUrl],
  326. 'type': 'Announce'
  327. }
  328. handle = httpPrefix + '://' + fromDomainFull + '/@' + fromNickname
  329. # lookup the inbox for the To handle
  330. wfRequest = webfingerHandle(session, handle, httpPrefix,
  331. cachedWebfingers,
  332. fromDomain, projectVersion)
  333. if not wfRequest:
  334. if debug:
  335. print('DEBUG: announce webfinger failed for ' + handle)
  336. return 1
  337. if not isinstance(wfRequest, dict):
  338. print('WARN: Webfinger for ' + handle + ' did not return a dict. ' +
  339. str(wfRequest))
  340. return 1
  341. postToBox = 'outbox'
  342. # get the actor inbox for the To handle
  343. (inboxUrl, pubKeyId, pubKey, fromPersonId,
  344. sharedInbox, capabilityAcquisition,
  345. avatarUrl, displayName) = getPersonBox(baseDir, session, wfRequest,
  346. personCache,
  347. projectVersion, httpPrefix,
  348. fromNickname, fromDomain,
  349. postToBox)
  350. if not inboxUrl:
  351. if debug:
  352. print('DEBUG: No ' + postToBox + ' was found for ' + handle)
  353. return 3
  354. if not fromPersonId:
  355. if debug:
  356. print('DEBUG: No actor was found for ' + handle)
  357. return 4
  358. authHeader = createBasicAuthHeader(fromNickname, password)
  359. headers = {
  360. 'host': fromDomain,
  361. 'Content-type': 'application/json',
  362. 'Authorization': authHeader
  363. }
  364. postResult = postJson(session, newAnnounceJson, [], inboxUrl,
  365. headers, "inbox:write")
  366. if not postResult:
  367. print('WARN: Announce not posted')
  368. if debug:
  369. print('DEBUG: c2s POST announce success')
  370. return newAnnounceJson