shares.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. __filename__ = "shares.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. import os
  11. import time
  12. from shutil import copyfile
  13. from webfinger import webfingerHandle
  14. from auth import createBasicAuthHeader
  15. from posts import getPersonBox
  16. from session import postJson
  17. from utils import validNickname
  18. from utils import getNicknameFromActor
  19. from utils import getDomainFromActor
  20. from utils import loadJson
  21. from utils import saveJson
  22. from media import removeMetaData
  23. def getValidSharedItemID(displayName: str) -> str:
  24. """Removes any invalid characters from the display name to
  25. produce an item ID
  26. """
  27. return displayName.replace(' ','').replace('+','-').replace('/','-').replace('\\','-').replace('.','_').replace('?','-').replace('\n','').replace("’","'").replace('&','-')
  28. def removeShare(baseDir: str,nickname: str,domain: str, \
  29. displayName: str) -> None:
  30. """Removes a share for a person
  31. """
  32. sharesFilename=baseDir+'/accounts/'+nickname+'@'+domain+'/shares.json'
  33. if not os.path.isfile(sharesFilename):
  34. print('ERROR: missing shares.json '+sharesFilename)
  35. return
  36. sharesJson=loadJson(sharesFilename)
  37. if not sharesJson:
  38. print('ERROR: shares.json could not be loaded from '+sharesFilename)
  39. return
  40. itemID=getValidSharedItemID(displayName)
  41. if sharesJson.get(itemID):
  42. # remove any image for the item
  43. itemIDfile=baseDir+'/sharefiles/'+nickname+'/'+itemID
  44. if sharesJson[itemID]['imageUrl']:
  45. if sharesJson[itemID]['imageUrl'].endswith('.png'):
  46. if os.path.isfile(itemIDfile+'.png'):
  47. os.remove(itemIDfile+'.png')
  48. if sharesJson[itemID]['imageUrl'].endswith('.jpg'):
  49. if os.path.isfile(itemIDfile+'.jpg'):
  50. os.remove(itemIDfile+'.jpg')
  51. if sharesJson[itemID]['imageUrl'].endswith('.gif'):
  52. if os.path.isfile(itemIDfile+'.gif'):
  53. os.remove(itemIDfile+'.gif')
  54. # remove the item itself
  55. del sharesJson[itemID]
  56. saveJson(sharesJson,sharesFilename)
  57. else:
  58. print('ERROR: share index "'+itemID+'" does not exist in '+sharesFilename)
  59. def addShare(baseDir: str, \
  60. httpPrefix: str,nickname: str,domain: str,port: int, \
  61. displayName: str, \
  62. summary: str, \
  63. imageFilename: str, \
  64. itemType: str, \
  65. itemCategory: str, \
  66. location: str, \
  67. duration: str,
  68. debug: bool) -> None:
  69. """Updates the likes collection within a post
  70. """
  71. sharesFilename=baseDir+'/accounts/'+nickname+'@'+domain+'/shares.json'
  72. sharesJson={}
  73. if os.path.isfile(sharesFilename):
  74. sharesJson=loadJson(sharesFilename)
  75. duration=duration.lower()
  76. durationSec=0
  77. published=int(time.time())
  78. if ' ' in duration:
  79. durationList=duration.split(' ')
  80. if durationList[0].isdigit():
  81. if 'hour' in durationList[1]:
  82. durationSec=published+(int(durationList[0])*60*60)
  83. if 'day' in durationList[1]:
  84. durationSec=published+(int(durationList[0])*60*60*24)
  85. if 'week' in durationList[1]:
  86. durationSec=published+(int(durationList[0])*60*60*24*7)
  87. if 'month' in durationList[1]:
  88. durationSec=published+(int(durationList[0])*60*60*24*30)
  89. if 'year' in durationList[1]:
  90. durationSec=published+(int(durationList[0])*60*60*24*365)
  91. itemID=getValidSharedItemID(displayName)
  92. # has an image for this share been uploaded?
  93. imageUrl=None
  94. moveImage=False
  95. if not imageFilename:
  96. sharesImageFilename=baseDir+'/accounts/'+nickname+'@'+domain+'/upload'
  97. if os.path.isfile(sharesImageFilename+'.png'):
  98. imageFilename=sharesImageFilename+'.png'
  99. moveImage=True
  100. elif os.path.isfile(sharesImageFilename+'.jpg'):
  101. imageFilename=sharesImageFilename+'.jpg'
  102. moveImage=True
  103. elif os.path.isfile(sharesImageFilename+'.gif'):
  104. imageFilename=sharesImageFilename+'.gif'
  105. moveImage=True
  106. domainFull=domain
  107. if port:
  108. if port!=80 and port!=443:
  109. if ':' not in domain:
  110. domainFull=domain+':'+str(port)
  111. # copy or move the image for the shared item to its destination
  112. if imageFilename:
  113. if os.path.isfile(imageFilename):
  114. if not os.path.isdir(baseDir+'/sharefiles'):
  115. os.mkdir(baseDir+'/sharefiles')
  116. if not os.path.isdir(baseDir+'/sharefiles/'+nickname):
  117. os.mkdir(baseDir+'/sharefiles/'+nickname)
  118. itemIDfile=baseDir+'/sharefiles/'+nickname+'/'+itemID
  119. if imageFilename.endswith('.png'):
  120. removeMetaData(imageFilename,itemIDfile+'.png')
  121. if moveImage:
  122. os.remove(imageFilename)
  123. imageUrl=httpPrefix+'://'+domainFull+'/sharefiles/'+nickname+'/'+itemID+'.png'
  124. if imageFilename.endswith('.jpg'):
  125. removeMetaData(imageFilename,itemIDfile+'.jpg')
  126. if moveImage:
  127. os.remove(imageFilename)
  128. imageUrl=httpPrefix+'://'+domainFull+'/sharefiles/'+nickname+'/'+itemID+'.jpg'
  129. if imageFilename.endswith('.gif'):
  130. removeMetaData(imageFilename,itemIDfile+'.gif')
  131. if moveImage:
  132. os.remove(imageFilename)
  133. imageUrl=httpPrefix+'://'+domainFull+'/sharefiles/'+nickname+'/'+itemID+'.gif'
  134. sharesJson[itemID] = {
  135. "displayName": displayName,
  136. "summary": summary,
  137. "imageUrl": imageUrl,
  138. "itemType": itemType,
  139. "category": itemCategory,
  140. "location": location,
  141. "published": published,
  142. "expire": durationSec
  143. }
  144. saveJson(sharesJson,sharesFilename)
  145. # indicate that a new share is available
  146. for subdir, dirs, files in os.walk(baseDir+'/accounts'):
  147. for handle in dirs:
  148. if '@' in handle:
  149. accountDir=baseDir+'/accounts/'+handle
  150. newShareFile=accountDir+'/.newShare'
  151. if not os.path.isfile(newShareFile):
  152. nickname=handle.split('@')[0]
  153. try:
  154. with open(newShareFile, 'w') as fp:
  155. fp.write(httpPrefix+'://'+domainFull+'/users/'+nickname+'/tlshares')
  156. except:
  157. pass
  158. def expireShares(baseDir: str) -> None:
  159. """Removes expired items from shares
  160. """
  161. for subdir,dirs,files in os.walk(baseDir+'/accounts'):
  162. for account in dirs:
  163. if '@' not in account:
  164. continue
  165. nickname=account.split('@')[0]
  166. domain=account.split('@')[1]
  167. expireSharesForAccount(baseDir,nickname,domain)
  168. def expireSharesForAccount(baseDir: str,nickname: str,domain: str) -> None:
  169. """Removes expired items from shares
  170. """
  171. handleDomain=domain
  172. if ':' in handleDomain:
  173. handleDomain=domain.split(':')[0]
  174. handle=nickname+'@'+handleDomain
  175. sharesFilename=baseDir+'/accounts/'+handle+'/shares.json'
  176. if os.path.isfile(sharesFilename):
  177. sharesJson=loadJson(sharesFilename)
  178. if sharesJson:
  179. currTime=int(time.time())
  180. deleteItemID=[]
  181. for itemID,item in sharesJson.items():
  182. if currTime>item['expire']:
  183. deleteItemID.append(itemID)
  184. if deleteItemID:
  185. for itemID in deleteItemID:
  186. del sharesJson[itemID]
  187. # remove any associated images
  188. itemIDfile=baseDir+'/sharefiles/'+nickname+'/'+itemID
  189. if os.path.isfile(itemIDfile+'.png'):
  190. os.remove(itemIDfile+'.png')
  191. if os.path.isfile(itemIDfile+'.jpg'):
  192. os.remove(itemIDfile+'.jpg')
  193. if os.path.isfile(itemIDfile+'.gif'):
  194. os.remove(itemIDfile+'.gif')
  195. saveJson(sharesJson,sharesFilename)
  196. def getSharesFeedForPerson(baseDir: str, \
  197. domain: str,port: int, \
  198. path: str,httpPrefix: str, \
  199. sharesPerPage=12) -> {}:
  200. """Returns the shares for an account from GET requests
  201. """
  202. if '/shares' not in path:
  203. return None
  204. # handle page numbers
  205. headerOnly=True
  206. pageNumber=None
  207. if '?page=' in path:
  208. pageNumber=path.split('?page=')[1]
  209. if pageNumber=='true':
  210. pageNumber=1
  211. else:
  212. try:
  213. pageNumber=int(pageNumber)
  214. except:
  215. pass
  216. path=path.split('?page=')[0]
  217. headerOnly=False
  218. if not path.endswith('/shares'):
  219. return None
  220. nickname=None
  221. if path.startswith('/users/'):
  222. nickname=path.replace('/users/','',1).replace('/shares','')
  223. if path.startswith('/@'):
  224. nickname=path.replace('/@','',1).replace('/shares','')
  225. if not nickname:
  226. return None
  227. if not validNickname(domain,nickname):
  228. return None
  229. if port:
  230. if port!=80 and port!=443:
  231. if ':' not in domain:
  232. domain=domain+':'+str(port)
  233. handleDomain=domain
  234. if ':' in handleDomain:
  235. handleDomain=domain.split(':')[0]
  236. handle=nickname+'@'+handleDomain
  237. sharesFilename=baseDir+'/accounts/'+handle+'/shares.json'
  238. if headerOnly:
  239. noOfShares=0
  240. if os.path.isfile(sharesFilename):
  241. sharesJson=loadJson(sharesFilename)
  242. if sharesJson:
  243. noOfShares=len(sharesJson.items())
  244. shares = {
  245. '@context': 'https://www.w3.org/ns/activitystreams',
  246. 'first': httpPrefix+'://'+domain+'/users/'+nickname+'/shares?page=1',
  247. 'id': httpPrefix+'://'+domain+'/users/'+nickname+'/shares',
  248. 'totalItems': str(noOfShares),
  249. 'type': 'OrderedCollection'}
  250. return shares
  251. if not pageNumber:
  252. pageNumber=1
  253. nextPageNumber=int(pageNumber+1)
  254. shares = {
  255. '@context': 'https://www.w3.org/ns/activitystreams',
  256. 'id': httpPrefix+'://'+domain+'/users/'+nickname+'/shares?page='+str(pageNumber),
  257. 'orderedItems': [],
  258. 'partOf': httpPrefix+'://'+domain+'/users/'+nickname+'/shares',
  259. 'totalItems': 0,
  260. 'type': 'OrderedCollectionPage'}
  261. if not os.path.isfile(sharesFilename):
  262. print("test5")
  263. return shares
  264. currPage=1
  265. pageCtr=0
  266. totalCtr=0
  267. sharesJson=loadJson(sharesFilename)
  268. if sharesJson:
  269. for itemID,item in sharesJson.items():
  270. pageCtr += 1
  271. totalCtr += 1
  272. if currPage==pageNumber:
  273. shares['orderedItems'].append(item)
  274. if pageCtr>=sharesPerPage:
  275. pageCtr=0
  276. currPage += 1
  277. shares['totalItems']=totalCtr
  278. lastPage=int(totalCtr/sharesPerPage)
  279. if lastPage<1:
  280. lastPage=1
  281. if nextPageNumber>lastPage:
  282. shares['next']=httpPrefix+'://'+domain+'/users/'+nickname+'/shares?page='+str(lastPage)
  283. return shares
  284. def sendShareViaServer(baseDir,session, \
  285. fromNickname: str,password: str, \
  286. fromDomain: str,fromPort: int, \
  287. httpPrefix: str, \
  288. displayName: str, \
  289. summary: str, \
  290. imageFilename: str, \
  291. itemType: str, \
  292. itemCategory: str, \
  293. location: str, \
  294. duration: str, \
  295. cachedWebfingers: {},personCache: {}, \
  296. debug: bool, \
  297. projectVersion: str) -> {}:
  298. """Creates an item share via c2s
  299. """
  300. if not session:
  301. print('WARN: No session for sendShareViaServer')
  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. newShareJson = {
  311. "@context": "https://www.w3.org/ns/activitystreams",
  312. 'type': 'Add',
  313. 'actor': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname,
  314. 'target': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname+'/shares',
  315. 'object': {
  316. "type": "Offer",
  317. "displayName": displayName,
  318. "summary": summary,
  319. "itemType": itemType,
  320. "category": category,
  321. "location": location,
  322. "duration": duration,
  323. 'to': [toUrl],
  324. 'cc': [ccUrl]
  325. },
  326. 'to': [toUrl],
  327. 'cc': [ccUrl]
  328. }
  329. handle=httpPrefix+'://'+fromDomainFull+'/@'+fromNickname
  330. # lookup the inbox for the To handle
  331. wfRequest = webfingerHandle(session,handle,httpPrefix,cachedWebfingers, \
  332. fromDomain,projectVersion)
  333. if not wfRequest:
  334. if debug:
  335. print('DEBUG: announce webfinger failed for '+handle)
  336. return 1
  337. postToBox='outbox'
  338. # get the actor inbox for the To handle
  339. inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl,displayName = \
  340. getPersonBox(baseDir,session,wfRequest,personCache, \
  341. projectVersion,httpPrefix, \
  342. fromNickname,fromDomain,postToBox)
  343. if not inboxUrl:
  344. if debug:
  345. print('DEBUG: No '+postToBox+' was found for '+handle)
  346. return 3
  347. if not fromPersonId:
  348. if debug:
  349. print('DEBUG: No actor was found for '+handle)
  350. return 4
  351. authHeader=createBasicAuthHeader(fromNickname,password)
  352. if imageFilename:
  353. headers = {'host': fromDomain, \
  354. 'Authorization': authHeader}
  355. postResult = \
  356. postImage(session,imageFilename,[],inboxUrl.replace('/'+postToBox,'/shares'),headers,"inbox:write")
  357. headers = {'host': fromDomain, \
  358. 'Content-type': 'application/json', \
  359. 'Authorization': authHeader}
  360. postResult = \
  361. postJson(session,newShareJson,[],inboxUrl,headers,"inbox:write")
  362. #if not postResult:
  363. # if debug:
  364. # print('DEBUG: POST announce failed for c2s to '+inboxUrl)
  365. # return 5
  366. if debug:
  367. print('DEBUG: c2s POST share item success')
  368. return newShareJson
  369. def sendUndoShareViaServer(baseDir: str,session, \
  370. fromNickname: str,password: str, \
  371. fromDomain: str,fromPort: int, \
  372. httpPrefix: str, \
  373. displayName: str, \
  374. cachedWebfingers: {},personCache: {}, \
  375. debug: bool,projectVersion: str) -> {}:
  376. """Undoes a share via c2s
  377. """
  378. if not session:
  379. print('WARN: No session for sendUndoShareViaServer')
  380. return 6
  381. fromDomainFull=fromDomain
  382. if fromPort:
  383. if fromPort!=80 and fromPort!=443:
  384. if ':' not in fromDomain:
  385. fromDomainFull=fromDomain+':'+str(fromPort)
  386. toUrl = 'https://www.w3.org/ns/activitystreams#Public'
  387. ccUrl = httpPrefix + '://'+fromDomainFull+'/users/'+fromNickname+'/followers'
  388. undoShareJson = {
  389. "@context": "https://www.w3.org/ns/activitystreams",
  390. 'type': 'Remove',
  391. 'actor': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname,
  392. 'target': httpPrefix+'://'+fromDomainFull+'/users/'+fromNickname+'/shares',
  393. 'object': {
  394. "type": "Offer",
  395. "displayName": displayName,
  396. 'to': [toUrl],
  397. 'cc': [ccUrl]
  398. },
  399. 'to': [toUrl],
  400. 'cc': [ccUrl]
  401. }
  402. handle=httpPrefix+'://'+fromDomainFull+'/@'+fromNickname
  403. # lookup the inbox for the To handle
  404. wfRequest = webfingerHandle(session,handle,httpPrefix,cachedWebfingers, \
  405. fromDomain,projectVersion)
  406. if not wfRequest:
  407. if debug:
  408. print('DEBUG: announce webfinger failed for '+handle)
  409. return 1
  410. postToBox='outbox'
  411. # get the actor inbox for the To handle
  412. inboxUrl,pubKeyId,pubKey,fromPersonId,sharedInbox,capabilityAcquisition,avatarUrl,displayName = \
  413. getPersonBox(baseDir,session,wfRequest,personCache, \
  414. projectVersion,httpPrefix, \
  415. fromNickname,fromDomain,postToBox)
  416. if not inboxUrl:
  417. if debug:
  418. print('DEBUG: No '+postToBox+' was found for '+handle)
  419. return 3
  420. if not fromPersonId:
  421. if debug:
  422. print('DEBUG: No actor was found for '+handle)
  423. return 4
  424. authHeader=createBasicAuthHeader(fromNickname,password)
  425. headers = {'host': fromDomain, \
  426. 'Content-type': 'application/json', \
  427. 'Authorization': authHeader}
  428. postResult = \
  429. postJson(session,undoShareJson,[],inboxUrl,headers,"inbox:write")
  430. #if not postResult:
  431. # if debug:
  432. # print('DEBUG: POST announce failed for c2s to '+inboxUrl)
  433. # return 5
  434. if debug:
  435. print('DEBUG: c2s POST undo share success')
  436. return undoShareJson
  437. def outboxShareUpload(baseDir: str,httpPrefix: str, \
  438. nickname: str,domain: str,port: int, \
  439. messageJson: {},debug: bool) -> None:
  440. """ When a shared item is received by the outbox from c2s
  441. """
  442. if not messageJson.get('type'):
  443. return
  444. if not messageJson['type']=='Add':
  445. return
  446. if not messageJson.get('object'):
  447. return
  448. if not isinstance(messageJson['object'], dict):
  449. return
  450. if not messageJson['object'].get('type'):
  451. if debug:
  452. print('DEBUG: undo block - no type')
  453. return
  454. if not messageJson['object']['type']=='Offer':
  455. if debug:
  456. print('DEBUG: not an Offer activity')
  457. return
  458. if not messageJson['object'].get('displayName'):
  459. if debug:
  460. print('DEBUG: displayName missing from Offer')
  461. return
  462. if not messageJson['object'].get('summary'):
  463. if debug:
  464. print('DEBUG: summary missing from Offer')
  465. return
  466. if not messageJson['object'].get('itemType'):
  467. if debug:
  468. print('DEBUG: itemType missing from Offer')
  469. return
  470. if not messageJson['object'].get('category'):
  471. if debug:
  472. print('DEBUG: category missing from Offer')
  473. return
  474. if not messageJson['object'].get('location'):
  475. if debug:
  476. print('DEBUG: location missing from Offer')
  477. return
  478. if not messageJson['object'].get('duration'):
  479. if debug:
  480. print('DEBUG: duration missing from Offer')
  481. return
  482. addShare(baseDir, \
  483. httpPrefix,nickname,domain,port, \
  484. messageJson['object']['displayName'], \
  485. messageJson['object']['summary'], \
  486. messageJson['object']['imageFilename'], \
  487. messageJson['object']['itemType'], \
  488. messageJson['object']['itemCategory'], \
  489. messageJson['object']['location'], \
  490. messageJson['object']['duration'], \
  491. debug)
  492. if debug:
  493. print('DEBUG: shared item received via c2s')
  494. def outboxUndoShareUpload(baseDir: str,httpPrefix: str, \
  495. nickname: str,domain: str,port: int, \
  496. messageJson: {},debug: bool) -> None:
  497. """ When a shared item is removed via c2s
  498. """
  499. if not messageJson.get('type'):
  500. return
  501. if not messageJson['type']=='Remove':
  502. return
  503. if not messageJson.get('object'):
  504. return
  505. if not isinstance(messageJson['object'], dict):
  506. return
  507. if not messageJson['object'].get('type'):
  508. if debug:
  509. print('DEBUG: undo block - no type')
  510. return
  511. if not messageJson['object']['type']=='Offer':
  512. if debug:
  513. print('DEBUG: not an Offer activity')
  514. return
  515. if not messageJson['object'].get('displayName'):
  516. if debug:
  517. print('DEBUG: displayName missing from Offer')
  518. return
  519. removeShare(baseDir,nickname,domain, \
  520. messageJson['object']['displayName'])
  521. if debug:
  522. print('DEBUG: shared item removed via c2s')