announce.py 21 KB

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