1
0

share.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. const APIError = require("../../api/APIError");
  2. const { Sequence } = require("../../codex/Sequence");
  3. const config = require("../../config");
  4. const { WorkList } = require("../../util/workutil");
  5. const validator = require('validator');
  6. const { get_user, get_app } = require("../../helpers");
  7. const { PermissionUtil } = require("../../services/auth/PermissionService");
  8. const FSNodeParam = require("../../api/filesystem/FSNodeParam");
  9. const { TYPE_DIRECTORY } = require("../../filesystem/FSNodeContext");
  10. const { UsernameNotifSelector } = require("../../services/NotificationService");
  11. const { quot } = require("../../util/strutil");
  12. /*
  13. This code is optimized for editors supporting folding.
  14. Fold at Level 2 to conveniently browse sequence steps.
  15. Fold at Level 3 after opening an inner-sequence.
  16. If you're using VSCode {
  17. typically "Ctrl+K, Ctrl+2" or "⌘K, ⌘2";
  18. to revert "Ctrl+K, Ctrl+J" or "⌘K, ⌘J";
  19. https://stackoverflow.com/questions/30067767
  20. }
  21. */
  22. module.exports = new Sequence([
  23. function validate_mode (a) {
  24. const req = a.get('req');
  25. const mode = req.body.mode;
  26. if ( mode === 'strict' ) {
  27. a.set('strict_mode', true);
  28. return;
  29. }
  30. if ( ! mode || mode === 'best-effort' ) {
  31. a.set('strict_mode', false);
  32. return;
  33. }
  34. throw APIError.create('field_invalid', null, {
  35. key: 'mode',
  36. expected: '`strict`, `best-effort`, or undefined',
  37. });
  38. },
  39. function validate_recipients (a) {
  40. const req = a.get('req');
  41. let recipients = req.body.recipients;
  42. // A string can be adapted to an array of one string
  43. if ( typeof recipients === 'string' ) {
  44. recipients = [recipients];
  45. }
  46. // Must be an array
  47. if ( ! Array.isArray(recipients) ) {
  48. throw APIError.create('field_invalid', null, {
  49. key: 'recipients',
  50. expected: 'array or string',
  51. got: typeof recipients,
  52. })
  53. }
  54. // At least one recipient
  55. if ( recipients.length < 1 ) {
  56. throw APIError.create('field_invalid', null, {
  57. key: 'recipients',
  58. expected: 'at least one',
  59. got: 'none',
  60. });
  61. }
  62. a.set('req_recipients', recipients);
  63. },
  64. function validate_shares (a) {
  65. const req = a.get('req');
  66. let shares = req.body.shares;
  67. if ( ! Array.isArray(shares) ) {
  68. shares = [shares];
  69. }
  70. // At least one share
  71. if ( shares.length < 1 ) {
  72. throw APIError.create('field_invalid', null, {
  73. key: 'shares',
  74. expected: 'at least one',
  75. got: 'none',
  76. });
  77. }
  78. a.set('req_shares', shares);
  79. },
  80. function initialize_result_object (a) {
  81. a.set('result', {
  82. $: 'api:share',
  83. $version: 'v0.0.0',
  84. status: null,
  85. recipients:
  86. Array(a.get('req_recipients').length).fill(null),
  87. shares:
  88. Array(a.get('req_shares').length).fill(null),
  89. serialize () {
  90. const result = this;
  91. for ( let i=0 ; i < result.recipients.length ; i++ ) {
  92. if ( ! result.recipients[i] ) continue;
  93. if ( result.recipients[i] instanceof APIError ) {
  94. result.status = 'mixed';
  95. result.recipients[i] = result.recipients[i].serialize();
  96. }
  97. }
  98. for ( let i=0 ; i < result.shares.length ; i++ ) {
  99. if ( ! result.shares[i] ) continue;
  100. if ( result.shares[i] instanceof APIError ) {
  101. result.status = 'mixed';
  102. result.shares[i] = result.shares[i].serialize();
  103. }
  104. }
  105. delete result.serialize;
  106. return result;
  107. }
  108. });
  109. },
  110. function initialize_worklists (a) {
  111. const recipients_work = new WorkList();
  112. const shares_work = new WorkList();
  113. const { req_recipients, req_shares } = a.values();
  114. // track: common operations on multiple items
  115. for ( let i=0 ; i < req_recipients.length ; i++ ) {
  116. const value = req_recipients[i];
  117. recipients_work.push({ i, value });
  118. }
  119. for ( let i=0 ; i < req_shares.length ; i++ ) {
  120. const value = req_shares[i];
  121. shares_work.push({ i, value });
  122. }
  123. recipients_work.lockin();
  124. shares_work.lockin();
  125. a.values({ recipients_work, shares_work });
  126. },
  127. new Sequence({ name: 'process recipients',
  128. after_each (a) {
  129. const { recipients_work } = a.values();
  130. recipients_work.clear_invalid();
  131. }
  132. }, [
  133. function valid_username_or_email (a) {
  134. const { result, recipients_work } = a.values();
  135. for ( const item of recipients_work.list() ) {
  136. const { value, i } = item;
  137. if ( typeof value !== 'string' ) {
  138. item.invalid = true;
  139. result.recipients[i] =
  140. APIError.create('invalid_username_or_email', null, {
  141. value,
  142. });
  143. continue;
  144. }
  145. if ( value.match(config.username_regex) ) {
  146. item.type = 'username';
  147. continue;
  148. }
  149. if ( validator.isEmail(value) ) {
  150. item.type = 'email';
  151. continue;
  152. }
  153. item.invalid = true;
  154. result.recipients[i] =
  155. APIError.create('invalid_username_or_email', null, {
  156. value,
  157. });
  158. }
  159. },
  160. async function check_existing_users_for_email_shares (a) {
  161. const { recipients_work } = a.values();
  162. for ( const recipient_item of recipients_work.list() ) {
  163. if ( recipient_item.type !== 'email' ) continue;
  164. const user = await get_user({
  165. email: recipient_item.value,
  166. });
  167. if ( ! user ) continue;
  168. recipient_item.type = 'username';
  169. recipient_item.value = user.username;
  170. }
  171. },
  172. async function check_username_specified_users_exist (a) {
  173. const { result, recipients_work } = a.values();
  174. for ( const item of recipients_work.list() ) {
  175. if ( item.type !== 'username' ) continue;
  176. const user = await get_user({ username: item.value });
  177. if ( ! user ) {
  178. item.invalid = true;
  179. result.recipients[item.i] =
  180. APIError.create('user_does_not_exist', null, {
  181. username: item.value,
  182. });
  183. continue;
  184. }
  185. item.user = user;
  186. }
  187. }
  188. ]),
  189. new Sequence({ name: 'process shares',
  190. beforeEach (a) {
  191. const { shares_work } = a.values();
  192. shares_work.clear_invalid();
  193. }
  194. }, [
  195. function validate_share_types (a) {
  196. const { result, shares_work } = a.values();
  197. const lib_typeTagged = a.iget('services').get('lib-type-tagged');
  198. for ( const item of shares_work.list() ) {
  199. const { i } = item;
  200. let { value } = item;
  201. const thing = lib_typeTagged.process(value);
  202. if ( thing.$ === 'error' ) {
  203. item.invalid = true;
  204. result.shares[i] =
  205. APIError.create('format_error', null, {
  206. message: thing.message
  207. });
  208. continue;
  209. }
  210. const allowed_things = ['fs-share', 'app-share'];
  211. if ( ! allowed_things.includes(thing.$) ) {
  212. item.invalid = true;
  213. result.shares[i] =
  214. APIError.create('disallowed_thing', null, {
  215. thing: thing.$,
  216. accepted: allowed_things,
  217. });
  218. continue;
  219. }
  220. item.thing = thing;
  221. }
  222. },
  223. function create_file_share_intents (a) {
  224. const { result, shares_work } = a.values();
  225. for ( const item of shares_work.list() ) {
  226. const { thing } = item;
  227. if ( thing.$ !== 'fs-share' ) continue;
  228. item.type = 'fs';
  229. const errors = [];
  230. if ( ! thing.path ) {
  231. errors.push('`path` is required');
  232. }
  233. let access = thing.access;
  234. if ( access ) {
  235. if ( ! ['read','write'].includes(access) ) {
  236. errors.push('`access` should be `read` or `write`');
  237. }
  238. } else access = 'read';
  239. if ( errors.length ) {
  240. item.invalid = true;
  241. result.shares[item.i] =
  242. APIError.create('field_errors', null, {
  243. key: `shares[${item.i}]`,
  244. errors
  245. });
  246. continue;
  247. }
  248. item.path = thing.path;
  249. item.share_intent = {
  250. $: 'share-intent:file',
  251. permissions: [PermissionUtil.join('fs', thing.path, access)],
  252. };
  253. }
  254. },
  255. function create_app_share_intents (a) {
  256. const { result, shares_work } = a.values();
  257. for ( const item of shares_work.list() ) {
  258. const { thing } = item;
  259. if ( thing.$ !== 'app-share' ) continue;
  260. item.type = 'app';
  261. const errors = [];
  262. if ( ! thing.uid && ! thing.name ) {
  263. errors.push('`uid` or `name` is required');
  264. }
  265. if ( errors.length ) {
  266. item.invalid = true;
  267. result.shares[item.i] =
  268. APIError.create('field_errors', null, {
  269. key: `shares[${item.i}]`,
  270. errors
  271. });
  272. continue;
  273. }
  274. const app_selector = thing.uid
  275. ? `uid#${thing.uid}` : thing.name;
  276. item.share_intent = {
  277. $: 'share-intent:app',
  278. permissions: [
  279. PermissionUtil.join('app', app_selector, 'access')
  280. ]
  281. }
  282. continue;
  283. }
  284. },
  285. async function fetch_nodes_for_file_shares (a) {
  286. const { req, result, shares_work } = a.values();
  287. for ( const item of shares_work.list() ) {
  288. if ( item.type !== 'fs' ) continue;
  289. const node = await (new FSNodeParam('path')).consolidate({
  290. req, getParam: () => item.path
  291. });
  292. if ( ! await node.exists() ) {
  293. item.invalid = true;
  294. result.shares[item.i] = APIError.create('subject_does_not_exist', {
  295. path: item.path,
  296. })
  297. continue;
  298. }
  299. item.node = node;
  300. let email_path = item.path;
  301. let is_dir = true;
  302. if ( await node.get('type') !== TYPE_DIRECTORY ) {
  303. is_dir = false;
  304. // remove last component
  305. email_path = email_path.slice(0, item.path.lastIndexOf('/')+1);
  306. }
  307. if ( email_path.startsWith('/') ) email_path = email_path.slice(1);
  308. const email_link = `${config.origin}/show/${email_path}`;
  309. item.is_dir = is_dir;
  310. item.email_link = email_link;
  311. }
  312. },
  313. async function fetch_apps_for_app_shares (a) {
  314. const { result, shares_work } = a.values();
  315. const db = a.iget('db');
  316. for ( const item of shares_work.list() ) {
  317. if ( item.type !== 'app' ) continue;
  318. const { thing } = item;
  319. const app = await get_app(thing.uid ?
  320. { uid: thing.uid } : { name: thing.name });
  321. if ( ! app ) {
  322. item.invalid = true;
  323. result.shares[item.i] =
  324. // note: since we're reporting `entity_not_found`
  325. // we will report the id as an entity-storage-compatible
  326. // identifier.
  327. APIError.create('entity_not_found', null, {
  328. identifier: thing.uid
  329. ? { uid: thing.uid }
  330. : { id: { name: thing.name } }
  331. });
  332. }
  333. app.metadata = db.case({
  334. mysql: () => app.metadata,
  335. otherwise: () => JSON.parse(app.metadata ?? '{}')
  336. })();
  337. item.app = app;
  338. }
  339. },
  340. async function add_subdomain_permissions (a) {
  341. const { shares_work } = a.values();
  342. const actor = a.get('actor');
  343. const db = a.iget('db');
  344. for ( const item of shares_work.list() ) {
  345. if ( item.type !== 'app' ) continue;
  346. const [subdomain] = await db.read(
  347. `SELECT * FROM subdomains WHERE associated_app_id = ? ` +
  348. `AND user_id = ? LIMIT 1`,
  349. [item.app.id, actor.type.user.id]
  350. );
  351. if ( ! subdomain ) continue;
  352. // The subdomain is also owned by this user, so we'll
  353. // add a permission for that as well
  354. const site_selector = `uid#${subdomain.uuid}`;
  355. item.share_intent.permissions.push(
  356. PermissionUtil.join('site', site_selector, 'access')
  357. )
  358. }
  359. },
  360. async function add_appdata_permissions (a) {
  361. const { result, shares_work } = a.values();
  362. for ( const item of shares_work.list() ) {
  363. if ( item.type !== 'app' ) continue;
  364. if ( ! item.app.metadata?.shared_appdata ) continue;
  365. const app_owner = await get_user({ id: item.app.owner_user_id });
  366. const appdatadir =
  367. `/${app_owner.username}/AppData/${item.app.uid}`;
  368. const appdatadir_perm =
  369. PermissionUtil.join('fs', appdatadir, 'write');
  370. item.share_intent.permissions.push(appdatadir_perm);
  371. }
  372. },
  373. function apply_success_status_to_shares (a) {
  374. const { result, shares_work } = a.values();
  375. for ( const item of shares_work.list() ) {
  376. result.shares[item.i] =
  377. {
  378. $: 'api:status-report',
  379. status: 'success',
  380. fields: {
  381. permission: item.permission,
  382. }
  383. };
  384. }
  385. },
  386. ]),
  387. function abort_on_error_if_mode_is_strict (a) {
  388. const strict_mode = a.get('strict_mode');
  389. if ( ! strict_mode ) return;
  390. const result = a.get('result');
  391. if (
  392. result.recipients.some(v => v !== null) ||
  393. result.shares.some(v => v !== null)
  394. ) {
  395. result.serialize();
  396. result.status = 'aborted';
  397. const res = a.get('res');
  398. res.status(218).send(result);
  399. a.stop();
  400. }
  401. },
  402. function early_return_on_dry_run (a) {
  403. if ( ! a.get('req').body.dry_run ) return;
  404. const { res, result, recipients_work } = a.values();
  405. for ( const item of recipients_work.list() ) {
  406. result.recipients[item.i] =
  407. { $: 'api:status-report', status: 'success' };
  408. }
  409. result.serialize();
  410. result.status = 'success';
  411. result.dry_run = true;
  412. res.send(result);
  413. a.stop();
  414. },
  415. async function grant_permissions_to_existing_users (a) {
  416. const {
  417. req, result, recipients_work, shares_work
  418. } = a.values();
  419. const svc_permission = a.iget('services').get('permission');
  420. const svc_notification = a.iget('services').get('notification');
  421. const actor = a.get('actor');
  422. for ( const recipient_item of recipients_work.list() ) {
  423. if ( recipient_item.type !== 'username' ) continue;
  424. const username = recipient_item.user.username;
  425. for ( const share_item of shares_work.list() ) {
  426. const permissions = share_item.share_intent.permissions;
  427. for ( const perm of permissions ) {
  428. await svc_permission.grant_user_user_permission(
  429. actor,
  430. username,
  431. perm,
  432. );
  433. }
  434. }
  435. const files = []; {
  436. for ( const item of shares_work.list() ) {
  437. if ( item.type !== 'file' ) continue;
  438. files.push(
  439. await item.node.getSafeEntry(),
  440. );
  441. }
  442. }
  443. const apps = []; {
  444. for ( const item of shares_work.list() ) {
  445. if ( item.type !== 'app' ) continue;
  446. // TODO: is there a general way to create a
  447. // client-safe app right now without
  448. // going through entity storage?
  449. // track: manual safe object
  450. apps.push(item.name
  451. ? item.name : await get_app({
  452. uid: item.uid,
  453. }));
  454. }
  455. }
  456. svc_notification.notify(UsernameNotifSelector(username), {
  457. source: 'sharing',
  458. icon: 'shared.svg',
  459. title: 'Files were shared with you!',
  460. template: 'file-shared-with-you',
  461. fields: {
  462. username: actor.type.user.username,
  463. files,
  464. },
  465. text: `The user ${quot(req.user.username)} shared ` +
  466. `${files.length} ` +
  467. (files.length === 1 ? 'file' : 'files') + ' ' +
  468. 'with you.',
  469. });
  470. result.recipients[recipient_item.i] =
  471. { $: 'api:status-report', status: 'success' };
  472. }
  473. },
  474. async function email_the_email_recipients (a) {
  475. const { actor, recipients_work, shares_work } = a.values();
  476. const svc_share = a.iget('services').get('share');
  477. const svc_token = a.iget('services').get('token');
  478. const svc_email = a.iget('services').get('token');
  479. for ( const recipient_item of recipients_work.list() ) {
  480. if ( recipient_item.type !== 'email' ) continue;
  481. const email = recipient_item.value;
  482. // data that gets stored in the `data` column of the share
  483. const data = {
  484. $: 'internal:share',
  485. $v: 'v0.0.0',
  486. permissions: [],
  487. };
  488. for ( const share_item of shares_work.list() ) {
  489. const permissions = share_item.share_intent.permissions;
  490. data.permissions.push(...permissions);
  491. }
  492. // track: scoping iife
  493. const share_token = await (async () => {
  494. const share_uid = await svc_share.create_share({
  495. issuer: actor,
  496. email,
  497. data,
  498. });
  499. return svc_token.sign('share', {
  500. $: 'token:share',
  501. $v: '0.0.0',
  502. uid: share_uid,
  503. }, {
  504. expiresIn: '14d'
  505. });
  506. })();
  507. const email_link =
  508. `${config.origin}?share_token=${share_token}`;
  509. await svc_email.send_email({ email }, 'share_by_email', {
  510. link: email_link,
  511. });
  512. }
  513. },
  514. function send_result (a) {
  515. const { res, result } = a.values();
  516. result.serialize();
  517. res.send(result);
  518. }
  519. ]);