like.py 21 KB

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