shares.py 22 KB

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