announce.py 20 KB

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