announce.py 19 KB

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