shares.py 20 KB

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