announce.py 19 KB

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