announce.py 19 KB

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