like.py 21 KB

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