share.js 19 KB

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