like.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. __filename__ = "like.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 urlPermitted
  12. from utils import getNicknameFromActor
  13. from utils import getDomainFromActor
  14. from utils import locatePost
  15. from posts import sendSignedJson
  16. from session import postJson
  17. from webfinger import webfingerHandle
  18. from auth import createBasicAuthHeader
  19. from posts import getPersonBox
  20. def undoLikesCollectionEntry(postFilename: str,objectUrl: str,actor: str,debug: bool) -> None:
  21. """Undoes a like for a particular actor
  22. """
  23. with open(postFilename, 'r') as fp:
  24. postJsonObject=commentjson.load(fp)
  25. if not postJsonObject.get('type'):
  26. return
  27. if postJsonObject['type']!='Create':
  28. return
  29. if not postJsonObject.get('object'):
  30. if debug:
  31. pprint(postJsonObject)
  32. print('DEBUG: post '+objectUrl+' has no object')
  33. return
  34. if not isinstance(postJsonObject['object'], dict):
  35. return
  36. if not postJsonObject['object'].get('likes'):
  37. return
  38. if not isinstance(postJsonObject['object']['likes'], dict):
  39. return
  40. if not postJsonObject['object']['likes'].get('items'):
  41. return
  42. totalItems=0
  43. if postJsonObject['object']['likes'].get('totalItems'):
  44. totalItems=postJsonObject['object']['likes']['totalItems']
  45. itemFound=False
  46. for likeItem in postJsonObject['object']['likes']['items']:
  47. if likeItem.get('actor'):
  48. if likeItem['actor']==actor:
  49. if debug:
  50. print('DEBUG: like was removed for '+actor)
  51. postJsonObject['object']['likes']['items'].remove(likeItem)
  52. itemFound=True
  53. break
  54. if itemFound:
  55. if totalItems==1:
  56. if debug:
  57. print('DEBUG: likes was removed from post')
  58. del postJsonObject['object']['likes']
  59. else:
  60. postJsonObject['object']['likes']['totalItems']=len(postJsonObject['likes']['items'])
  61. with open(postFilename, 'w') as fp:
  62. commentjson.dump(postJsonObject, fp, indent=4, sort_keys=False)
  63. def likedByPerson(postJsonObject: {}, nickname: str,domain: str) -> bool:
  64. """Returns True if the given post is liked by the given person
  65. """
  66. if noOfLikes(postJsonObject)==0:
  67. return False
  68. actorMatch=domain+'/users/'+nickname
  69. for item in postJsonObject['object']['likes']['items']:
  70. if item['actor'].endswith(actorMatch):
  71. return True
  72. return False
  73. def noOfLikes(postJsonObject: {}) -> int:
  74. """Returns the number of likes ona given post
  75. """
  76. if not postJsonObject.get('object'):
  77. return 0
  78. if not isinstance(postJsonObject['object'], dict):
  79. return 0
  80. if not postJsonObject['object'].get('likes'):
  81. return 0
  82. if not isinstance(postJsonObject['object']['likes'], dict):
  83. return 0
  84. if not postJsonObject['object']['likes'].get('items'):
  85. postJsonObject['object']['likes']['items']=[]
  86. postJsonObject['object']['likes']['totalItems']=0
  87. return len(postJsonObject['object']['likes']['items'])
  88. def updateLikesCollection(postFilename: str,objectUrl: str, actor: str,debug: bool) -> None:
  89. """Updates the likes collection within a post
  90. """
  91. with open(postFilename, 'r') as fp:
  92. postJsonObject=commentjson.load(fp)
  93. if not postJsonObject.get('object'):
  94. if debug:
  95. pprint(postJsonObject)
  96. print('DEBUG: post '+objectUrl+' has no object')
  97. return
  98. if not objectUrl.endswith('/likes'):
  99. objectUrl=objectUrl+'/likes'
  100. if not postJsonObject['object'].get('likes'):
  101. if debug:
  102. print('DEBUG: Adding initial likes to '+objectUrl)
  103. likesJson = {
  104. "@context": "https://www.w3.org/ns/activitystreams",
  105. 'id': objectUrl,
  106. 'type': 'Collection',
  107. "totalItems": 1,
  108. 'items': [{
  109. 'type': 'Like',
  110. 'actor': actor
  111. }]
  112. }
  113. postJsonObject['object']['likes']=likesJson
  114. else:
  115. if not postJsonObject['object']['likes'].get('items'):
  116. postJsonObject['object']['likes']['items']=[]
  117. for likeItem in postJsonObject['object']['likes']['items']:
  118. if likeItem.get('actor'):
  119. if likeItem['actor']==actor:
  120. return
  121. newLike={
  122. 'type': 'Like',
  123. 'actor': actor
  124. }
  125. postJsonObject['object']['likes']['items'].append(newLike)
  126. postJsonObject['object']['likes']['totalItems']=len(postJsonObject['likes']['items'])
  127. if debug:
  128. print('DEBUG: saving post with likes added')
  129. pprint(postJsonObject)
  130. with open(postFilename, 'w') as fp:
  131. commentjson.dump(postJsonObject, fp, indent=4, sort_keys=False)
  132. def like(session,baseDir: str,federationList: [],nickname: str,domain: str,port: int, \
  133. ccList: [],httpPrefix: str,objectUrl: str,clientToServer: bool, \
  134. sendThreads: [],postLog: [],personCache: {},cachedWebfingers: {}, \
  135. debug: bool,projectVersion: str) -> {}:
  136. """Creates a like
  137. actor is the person doing the liking
  138. 'to' might be a specific person (actor) whose post was liked
  139. object is typically the url of the message which was liked
  140. """
  141. if not urlPermitted(objectUrl,federationList,"inbox:write"):
  142. return None
  143. fullDomain=domain
  144. if port:
  145. if port!=80 and port!=443:
  146. if ':' not in domain:
  147. fullDomain=domain+':'+str(port)
  148. likeTo=[]
  149. if '/statuses/' in objectUrl:
  150. likeTo=[objectUrl.split('/statuses/')[0]]
  151. newLikeJson = {
  152. "@context": "https://www.w3.org/ns/activitystreams",
  153. 'type': 'Like',
  154. 'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname,
  155. 'object': objectUrl
  156. }
  157. if ccList:
  158. if len(ccList)>0:
  159. newLikeJson['cc']=ccList
  160. # Extract the domain and nickname from a statuses link
  161. likedPostNickname=None
  162. likedPostDomain=None
  163. likedPostPort=None
  164. if '/users/' in objectUrl or '/profile/' in objectUrl:
  165. likedPostNickname=getNicknameFromActor(objectUrl)
  166. likedPostDomain,likedPostPort=getDomainFromActor(objectUrl)
  167. if likedPostNickname:
  168. postFilename=locatePost(baseDir,nickname,domain,objectUrl)
  169. if not postFilename:
  170. print('DEBUG: like baseDir: '+baseDir)
  171. print('DEBUG: like nickname: '+nickname)
  172. print('DEBUG: like domain: '+domain)
  173. print('DEBUG: like objectUrl: '+objectUrl)
  174. return None
  175. updateLikesCollection(postFilename,objectUrl,newLikeJson['actor'],debug)
  176. sendSignedJson(newLikeJson,session,baseDir, \
  177. nickname,domain,port, \
  178. likedPostNickname,likedPostDomain,likedPostPort, \
  179. 'https://www.w3.org/ns/activitystreams#Public', \
  180. httpPrefix,True,clientToServer,federationList, \
  181. sendThreads,postLog,cachedWebfingers,personCache, \
  182. debug,projectVersion)
  183. return newLikeJson
  184. def likePost(session,baseDir: str,federationList: [], \
  185. nickname: str,domain: str,port: int,httpPrefix: str, \
  186. likeNickname: str,likeDomain: str,likePort: int, \
  187. ccList: [], \
  188. likeStatusNumber: int,clientToServer: bool, \
  189. sendThreads: [],postLog: [], \
  190. personCache: {},cachedWebfingers: {}, \
  191. debug: bool,projectVersion: str) -> {}:
  192. """Likes a given status post
  193. """
  194. likeDomain=likeDomain
  195. if likePort:
  196. if likePort!=80 and likePort!=443:
  197. if ':' not in likeDomain:
  198. likeDomain=likeDomain+':'+str(likePort)
  199. objectUrl = \
  200. httpPrefix + '://'+likeDomain+'/users/'+likeNickname+ \
  201. '/statuses/'+str(likeStatusNumber)
  202. ccUrl=httpPrefix+'://'+likeDomain+'/users/'+likeNickname
  203. if likePort:
  204. if likePort!=80 and likePort!=443:
  205. if ':' not in likeDomain:
  206. ccUrl=httpPrefix+'://'+likeDomain+':'+str(likePort)+'/users/'+likeNickname
  207. return like(session,baseDir,federationList,nickname,domain,port, \
  208. ccList,httpPrefix,objectUrl,clientToServer, \
  209. sendThreads,postLog,personCache,cachedWebfingers, \
  210. debug,projectVersion)
  211. def undolike(session,baseDir: str,federationList: [],nickname: str,domain: str,port: int, \
  212. ccList: [],httpPrefix: str,objectUrl: str,clientToServer: bool, \
  213. sendThreads: [],postLog: [],personCache: {},cachedWebfingers: {}, \
  214. debug: bool,projectVersion: str) -> {}:
  215. """Removes a like
  216. actor is the person doing the liking
  217. 'to' might be a specific person (actor) whose post was liked
  218. object is typically the url of the message which was liked
  219. """
  220. if not urlPermitted(objectUrl,federationList,"inbox:write"):
  221. return None
  222. fullDomain=domain
  223. if port:
  224. if port!=80 and port!=443:
  225. if ':' not in domain:
  226. fullDomain=domain+':'+str(port)
  227. likeTo=[]
  228. if '/statuses/' in objectUrl:
  229. likeTo=[objectUrl.split('/statuses/')[0]]
  230. newUndoLikeJson = {
  231. "@context": "https://www.w3.org/ns/activitystreams",
  232. 'type': 'Undo',
  233. 'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname,
  234. 'object': {
  235. 'type': 'Like',
  236. 'actor': httpPrefix+'://'+fullDomain+'/users/'+nickname,
  237. 'object': objectUrl
  238. }
  239. }
  240. if ccList:
  241. if len(ccList)>0:
  242. newUndoLikeJson['cc']=ccList
  243. newUndoLikeJson['object']['cc']=ccList
  244. # Extract the domain and nickname from a statuses link
  245. likedPostNickname=None
  246. likedPostDomain=None
  247. likedPostPort=None
  248. if '/users/' in objectUrl or '/profile/' in objectUrl:
  249. likedPostNickname=getNicknameFromActor(objectUrl)
  250. likedPostDomain,likedPostPort=getDomainFromActor(objectUrl)
  251. if likedPostNickname:
  252. postFilename=locatePost(baseDir,nickname,domain,objectUrl)
  253. if not postFilename:
  254. return None
  255. undoLikesCollectionEntry(postFilename,objectUrl,newLikeJson['actor'],debug)
  256. sendSignedJson(newUndoLikeJson,session,baseDir, \
  257. nickname,domain,port, \
  258. likedPostNickname,likedPostDomain,likedPostPort, \
  259. 'https://www.w3.org/ns/activitystreams#Public', \
  260. httpPrefix,True,clientToServer,federationList, \
  261. sendThreads,postLog,cachedWebfingers,personCache, \
  262. debug,projectVersion)
  263. else:
  264. return None
  265. return newUndoLikeJson
  266. def undoLikePost(session,baseDir: str,federationList: [], \
  267. nickname: str,domain: str,port: int,httpPrefix: str, \
  268. likeNickname: str,likeDomain: str,likePort: int, \
  269. ccList: [], \
  270. likeStatusNumber: int,clientToServer: bool, \
  271. sendThreads: [],postLog: [], \
  272. personCache: {},cachedWebfingers: {}, \
  273. debug: bool) -> {}:
  274. """Removes a liked post
  275. """
  276. likeDomain=likeDomain
  277. if likePort:
  278. if likePort!=80 and likePort!=443:
  279. if ':' not in likeDomain:
  280. likeDomain=likeDomain+':'+str(likePort)
  281. objectUrl = \
  282. httpPrefix + '://'+likeDomain+'/users/'+likeNickname+ \
  283. '/statuses/'+str(likeStatusNumber)
  284. ccUrl=httpPrefix+'://'+likeDomain+'/users/'+likeNickname
  285. if likePort:
  286. if likePort!=80 and likePort!=443:
  287. if ':' not in likeDomain:
  288. ccUrl=httpPrefix+'://'+likeDomain+':'+str(likePort)+'/users/'+likeNickname
  289. return undoLike(session,baseDir,federationList,nickname,domain,port, \
  290. ccList,httpPrefix,objectUrl,clientToServer, \
  291. sendThreads,postLog,personCache,cachedWebfingers,debug)
  292. def sendLikeViaServer(baseDir: str,session, \
  293. fromNickname: str,password: str,
  294. fromDomain: str,fromPort: int, \
  295. httpPrefix: str,likeUrl: str, \
  296. cachedWebfingers: {},personCache: {}, \
  297. debug: bool,projectVersion: str) -> {}:
  298. """Creates a like via c2s
  299. """
  300. if not session:
  301. print('WARN: No session for sendLikeViaServer')
  302. return 6
  303. fromDomainFull=fromDomain
  304. if fromPort:
  305. if fromPort!=80 and fromPort!=443:
  306. if ':' not in fromDomain:
  307. fromDomainFull=fromDomain+':'+str(fromPort)
  308. toUrl = ['https://www.w3.org/ns/activitystreams#Public']
  309. ccUrl = httpPrefix + '://'+fromDomainFull+'/users/'+fromNickname+'/followers'
  310. if '/statuses/' in likeUrl:
  311. toUrl=[likeUrl.split('/statuses/')[0]]
  312. newLikeJson = {
  313. "@context": "https://www.w3.org/ns/activitystreams",
  314. 'type': 'Like',
  315. 'actor': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname,
  316. 'object': likeUrl
  317. }
  318. handle=httpPrefix+'://'+fromDomainFull+'/@'+fromNickname
  319. # lookup the inbox for the To handle
  320. wfRequest = webfingerHandle(session,handle,httpPrefix,cachedWebfingers, \
  321. fromDomain,projectVersion)
  322. if not wfRequest:
  323. if debug:
  324. print('DEBUG: announce webfinger failed for '+handle)
  325. return 1
  326. postToBox='outbox'
  327. # get the actor inbox for the To handle
  328. inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl,displayName = \
  329. getPersonBox(baseDir,session,wfRequest,personCache, \
  330. projectVersion,httpPrefix,fromDomain,postToBox)
  331. if not inboxUrl:
  332. if debug:
  333. print('DEBUG: No '+postToBox+' was found for '+handle)
  334. return 3
  335. if not fromPersonId:
  336. if debug:
  337. print('DEBUG: No actor was found for '+handle)
  338. return 4
  339. authHeader=createBasicAuthHeader(fromNickname,password)
  340. headers = {'host': fromDomain, \
  341. 'Content-type': 'application/json', \
  342. 'Authorization': authHeader}
  343. postResult = \
  344. postJson(session,newLikeJson,[],inboxUrl,headers,"inbox:write")
  345. #if not postResult:
  346. # if debug:
  347. # print('DEBUG: POST announce failed for c2s to '+inboxUrl)
  348. # return 5
  349. if debug:
  350. print('DEBUG: c2s POST like success')
  351. return newLikeJson
  352. def sendUndoLikeViaServer(baseDir: str,session, \
  353. fromNickname: str,password: str, \
  354. fromDomain: str,fromPort: int, \
  355. httpPrefix: str,likeUrl: str, \
  356. cachedWebfingers: {},personCache: {}, \
  357. debug: bool,projectVersion: str) -> {}:
  358. """Undo a like via c2s
  359. """
  360. if not session:
  361. print('WARN: No session for sendUndoLikeViaServer')
  362. return 6
  363. fromDomainFull=fromDomain
  364. if fromPort:
  365. if fromPort!=80 and fromPort!=443:
  366. if ':' not in fromDomain:
  367. fromDomainFull=fromDomain+':'+str(fromPort)
  368. toUrl = ['https://www.w3.org/ns/activitystreams#Public']
  369. ccUrl = httpPrefix + '://'+fromDomainFull+'/users/'+fromNickname+'/followers'
  370. if '/statuses/' in likeUrl:
  371. toUrl=[likeUrl.split('/statuses/')[0]]
  372. newUndoLikeJson = {
  373. "@context": "https://www.w3.org/ns/activitystreams",
  374. 'type': 'Undo',
  375. 'actor': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname,
  376. 'object': {
  377. 'type': 'Like',
  378. 'actor': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname,
  379. 'object': likeUrl
  380. }
  381. }
  382. handle=httpPrefix+'://'+fromDomainFull+'/@'+fromNickname
  383. # lookup the inbox for the To handle
  384. wfRequest = webfingerHandle(session,handle,httpPrefix,cachedWebfingers, \
  385. fromDomain,projectVersion)
  386. if not wfRequest:
  387. if debug:
  388. print('DEBUG: announce webfinger failed for '+handle)
  389. return 1
  390. postToBox='outbox'
  391. # get the actor inbox for the To handle
  392. inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl,displayName = \
  393. getPersonBox(baseDir,session,wfRequest,personCache, \
  394. projectVersion,httpPrefix,fromDomain,postToBox)
  395. if not inboxUrl:
  396. if debug:
  397. print('DEBUG: No '+postToBox+' was found for '+handle)
  398. return 3
  399. if not fromPersonId:
  400. if debug:
  401. print('DEBUG: No actor was found for '+handle)
  402. return 4
  403. authHeader=createBasicAuthHeader(fromNickname,password)
  404. headers = {'host': fromDomain, \
  405. 'Content-type': 'application/json', \
  406. 'Authorization': authHeader}
  407. postResult = \
  408. postJson(session,newUndoLikeJson,[],inboxUrl,headers,"inbox:write")
  409. #if not postResult:
  410. # if debug:
  411. # print('DEBUG: POST announce failed for c2s to '+inboxUrl)
  412. # return 5
  413. if debug:
  414. print('DEBUG: c2s POST undo like success')
  415. return newUndoLikeJson
  416. def outboxLike(baseDir: str,httpPrefix: str, \
  417. nickname: str,domain: str,port: int, \
  418. messageJson: {},debug: bool) -> None:
  419. """ When a like request is received by the outbox from c2s
  420. """
  421. if not messageJson.get('type'):
  422. if debug:
  423. print('DEBUG: like - no type')
  424. return
  425. if not messageJson['type']=='Like':
  426. if debug:
  427. print('DEBUG: not a like')
  428. return
  429. if not messageJson.get('object'):
  430. if debug:
  431. print('DEBUG: no object in like')
  432. return
  433. if not isinstance(messageJson['object'], str):
  434. if debug:
  435. print('DEBUG: like object is not string')
  436. return
  437. if debug:
  438. print('DEBUG: c2s like request arrived in outbox')
  439. messageId=messageJson['object'].replace('/activity','')
  440. if '/statuses/' not in messageId:
  441. if debug:
  442. print('DEBUG: c2s like object is not a status')
  443. return
  444. if '/users/' not in messageId and '/profile/' not in messageId:
  445. if debug:
  446. print('DEBUG: c2s like object has no nickname')
  447. return
  448. if ':' in domain:
  449. domain=domain.split(':')[0]
  450. postFilename=locatePost(baseDir,nickname,domain,messageId)
  451. if not postFilename:
  452. if debug:
  453. print('DEBUG: c2s like post not found in inbox or outbox')
  454. print(messageId)
  455. return True
  456. updateLikesCollection(postFilename,messageId,messageJson['actor'],debug)
  457. if debug:
  458. print('DEBUG: post liked via c2s - '+postFilename)
  459. def outboxUndoLike(baseDir: str,httpPrefix: str, \
  460. nickname: str,domain: str,port: int, \
  461. messageJson: {},debug: bool) -> None:
  462. """ When an undo like request is received by the outbox from c2s
  463. """
  464. if not messageJson.get('type'):
  465. return
  466. if not messageJson['type']=='Undo':
  467. return
  468. if not messageJson.get('object'):
  469. return
  470. if not isinstance(messageJson['object'], dict):
  471. if debug:
  472. print('DEBUG: undo like object is not dict')
  473. return
  474. if not messageJson['object'].get('type'):
  475. if debug:
  476. print('DEBUG: undo like - no type')
  477. return
  478. if not messageJson['object']['type']=='Like':
  479. if debug:
  480. print('DEBUG: not a undo like')
  481. return
  482. if not messageJson['object'].get('object'):
  483. if debug:
  484. print('DEBUG: no object in undo like')
  485. return
  486. if not isinstance(messageJson['object']['object'], str):
  487. if debug:
  488. print('DEBUG: undo like object is not string')
  489. return
  490. if debug:
  491. print('DEBUG: c2s undo like request arrived in outbox')
  492. messageId=messageJson['object']['object'].replace('/activity','')
  493. if '/statuses/' not in messageId:
  494. if debug:
  495. print('DEBUG: c2s undo like object is not a status')
  496. return
  497. if '/users/' not in messageId and '/profile/' not in messageId:
  498. if debug:
  499. print('DEBUG: c2s undo like object has no nickname')
  500. return
  501. if ':' in domain:
  502. domain=domain.split(':')[0]
  503. postFilename=locatePost(baseDir,nickname,domain,messageId)
  504. if not postFilename:
  505. if debug:
  506. print('DEBUG: c2s undo like post not found in inbox or outbox')
  507. print(messageId)
  508. return True
  509. undoLikesCollectionEntry(postFilename,messageId,messageJson['actor'],debug)
  510. if debug:
  511. print('DEBUG: post undo liked via c2s - '+postFilename)