diff --git a/packages/backend/src/api/APIError.js b/packages/backend/src/api/APIError.js index bb0b483134..72dbb069be 100644 --- a/packages/backend/src/api/APIError.js +++ b/packages/backend/src/api/APIError.js @@ -393,6 +393,27 @@ module.exports = class APIError { status: 422, message: ({ username }) => `The user ${quot(username)} does not exist.` }, + 'invalid_username_or_email': { + status: 400, + message: ({ value }) => + `The value ${quot(value)} is not a valid username or email.` + }, + 'invalid_path': { + status: 400, + message: ({ value }) => + `The value ${quot(value)} is not a valid path.` + }, + 'future': { + status: 400, + message: ({ what }) => `Not supported yet: ${what}` + }, + // Temporary solution for lack of error composition + 'field_errors': { + status: 400, + message: ({ key, errors }) => + `The value for ${quot(key)} has the following errors: ` + + errors.join('; ') + }, // Chat // TODO: specifying these errors here might be a violation diff --git a/packages/backend/src/routers/share.js b/packages/backend/src/routers/share.js index 0135dfd28e..1906477a2e 100644 --- a/packages/backend/src/routers/share.js +++ b/packages/backend/src/routers/share.js @@ -15,6 +15,9 @@ const { validate } = require('uuid'); const configurable_auth = require('../middleware/configurable_auth'); const { UsernameNotifSelector } = require('../services/NotificationService'); const { quot } = require('../util/strutil'); +const { UtilFn } = require('../util/fnutil'); +const { WorkList } = require('../util/workutil'); +const { whatis } = require('../util/langutil'); const uuidv4 = require('uuid').v4; @@ -45,7 +48,7 @@ const validate_share_fsnode_params = req => { }; } -const handler_item_by_username = async (req, res) => { +const v0_1 = async (req, res) => { const svc_token = req.services.get('token'); const svc_email = req.services.get('email'); const svc_permission = req.services.get('permission'); @@ -130,12 +133,345 @@ const handler_item_by_username = async (req, res) => { res.send({}); }; + +const v0_2 = async (req, res) => { + const svc_token = req.services.get('token'); + const svc_email = req.services.get('email'); + const svc_permission = req.services.get('permission'); + const svc_notification = req.services.get('notification'); + + const actor = Context.get('actor'); + + // === Request Validators === + + const validate_mode = UtilFn(mode => { + if ( mode === 'strict' ) return true; + if ( ! mode || mode === 'best-effort' ) return false; + throw APIError.create('field_invalid', null, { + key: 'mode', + expected: '`strict`, `best-effort`, or undefined', + }); + }) + + // Expect: an array of usernames and/or emails + const validate_recipients = UtilFn(recipients => { + // A string can be adapted to an array of one string + if ( typeof recipients === 'string' ) { + recipients = [recipients]; + } + // Must be an array + if ( ! Array.isArray(recipients) ) { + throw APIError.create('field_invalid', null, { + key: 'recipients', + expected: 'array or string', + got: typeof recipients, + }) + } + return recipients; + }); + + const validate_paths = UtilFn(paths => { + // Single-values get adapted into an array + if ( ! Array.isArray(paths) ) { + paths = [paths]; + } + return paths; + }) + + // === Request Values === + + const mode = + validate_mode.if(req.body.mode) ?? false; + const req_recipients = + validate_recipients.if(req.body.recipients) ?? []; + const req_paths = + validate_paths.if(req.body.paths) ?? []; + + // === State Values === + + const recipients = []; + const result = { + recipients: Array(req_recipients.length).fill(null), + paths: Array(req_paths.length).fill(null), + } + const recipients_work = new WorkList(); + const fsitems_work = new WorkList(); + + // const assert_work_item = (wut, item) => { + // if ( item.$ !== wut ) { + // // This should never happen, so 500 is acceptable here + // throw new Error('work item assertion failed'); + // } + // } + + // === Request Preprocessing === + + // --- Function that returns early in strict mode --- + const serialize_result = () => { + for ( let i=0 ; i < result.recipients.length ; i++ ) { + if ( ! result.recipients[i] ) continue; + if ( result.recipients[i] instanceof APIError ) { + result.recipients[i] = result.recipients[i].serialize(); + } + } + for ( let i=0 ; i < result.paths.length ; i++ ) { + if ( ! result.paths[i] ) continue; + if ( result.paths[i] instanceof APIError ) { + result.paths[i] = result.paths[i].serialize(); + } + } + }; + const strict_check = () =>{ + if ( mode !== 'strict' ) return; + if ( + result.recipients.some(v => v !== null) || + result.paths.some(v => v !== null) + ) { + serialize_result(); + res.status(218).send(result); + return true; + } + } + + // --- Process Recipients --- + + // Expect: at least one recipient + if ( req_recipients.length < 1 ) { + throw APIError.create('field_invalid', null, { + key: 'recipients', + expected: 'at least one', + got: 'none', + }) + } + + for ( let i=0 ; i < req_recipients.length ; i++ ) { + const value = req_recipients[i]; + recipients_work.push({ i, value }) + } + recipients_work.lockin(); + + // Expect: each value should be a valid username or email + for ( const item of recipients_work.list() ) { + const { value, i } = item; + + if ( typeof value !== 'string' ) { + item.invalid = true; + result.recipients[i] = + APIError.create('invalid_username_of_email', null, { + value, + }) + } + + if ( value.match(config.username_regex) ) { + item.type = 'username'; + continue; + } + if ( validator.isEmail(value) ) { + item.type = 'username'; + continue; + } + + item.invalid = true; + result.recipients[i] = + APIError.create('invalid_username_or_email', null, { + value, + }); + } + + // Return: if there are invalid values in strict mode + recipients_work.clear_invalid(); + + // Expect: no emails specified yet + // AND usernames exist + for ( const item of recipients_work.list() ) { + if ( item.type === 'email' ) { + item.invalid = true; + result.recipients[item.i] = + APIError.create('future', null, { + what: 'specifying recipients by email' + }); + continue; + } + } + + // Return: if there are invalid values in strict mode + recipients_work.clear_invalid(); + + for ( const item of recipients_work.list() ) { + const user = await get_user({ username: item.value }); + if ( ! user ) { + item.invalid = true; + result.recipients[item.i] = + APIError.create('user_does_not_exist', null, { + username: item.value, + }); + continue; + } + item.user = user; + } + + // Return: if there are invalid values in strict mode + recipients_work.clear_invalid(); + + // --- Process Paths --- + + // Expect: at least one path + if ( req_paths.length < 1 ) { + throw APIError.create('field_invalid', null, { + key: 'paths', + expected: 'at least one', + got: 'none', + }) + } + + for ( let i=0 ; i < req_paths.length ; i++ ) { + const value = req_paths[i]; + fsitems_work.push({ i, value }); + } + fsitems_work.lockin(); + + for ( const item of fsitems_work.list() ) { + const { i } = item; + let { value } = item; + + // adapt all strings to objects + if ( typeof value === 'string' ) { + value = { path: value }; + } + + if ( whatis(value) !== 'object' ) { + item.invalid = true; + result.paths[i] = + APIError.create('invalid_path', null, { value }); + continue; + } + + const errors = []; + if ( ! value.path ) { + errors.push('`path` is required'); + } + let access = value.access; + if ( access ) { + if ( ! ['read','write'].includes(access) ) { + errors.push('`access` should be `read` or `write`'); + } + } else access = 'read'; + + if ( errors.length ) { + item.invalid = true; + result.paths[item.i] = + APIError.create('field_errors', null, { errors }); + continue; + } + + item.path = value.path; + item.permission = PermissionUtil.join('fs', value.path, access); + } + + fsitems_work.clear_invalid(); + + for ( const item of fsitems_work.list() ) { + const node = await (new FSNodeParam('path')).consolidate({ + req, getParam: () => item.path + }); + + if ( ! await node.exists() ) { + item.invalid = true; + result.paths[item.i] = APIError.create('subject_does_not_exist') + continue; + } + + item.node = node; + let email_path = item.path; + let is_dir = true; + if ( await node.get('type') !== TYPE_DIRECTORY ) { + is_dir = false; + // remove last component + email_path = email_path.slice(0, item.path.lastIndexOf('/')+1); + } + + if ( email_path.startsWith('/') ) email_path = email_path.slice(1); + const email_link = `${config.origin}/show/${email_path}`; + item.is_dir = is_dir; + item.email_link = email_link; + } + + fsitems_work.clear_invalid(); + + if ( strict_check() ) return; + + for ( const recipient_item of recipients_work.list() ) { + if ( recipient_item.type !== 'username' ) continue; + + const username = recipient_item.user.username; + + for ( const path_item of fsitems_work.list() ) { + await svc_permission.grant_user_user_permission( + actor, + username, + path_item.permission, + ); + } + + // TODO: Need to re-work this for multiple files + /* + const email_values = { + link: recipient_item.email_link, + susername: req.user.username, + rusername: username, + }; + + const email_tmpl = 'share_existing_user'; + + await svc_email.send_email( + { email: recipient_item.user.email }, + email_tmpl, + email_values, + ); + */ + + const files = []; { + for ( const path_item of fsitems_work.list() ) { + files.push( + await path_item.node.getSafeEntry(), + ); + } + } + + svc_notification.notify(UsernameNotifSelector(username), { + source: 'sharing', + icon: 'shared.svg', + title: 'Files were shared with you!', + template: 'file-shared-with-you', + fields: { + username, + files, + }, + text: `The user ${quot(req.user.username)} shared ` + + `${files.length} ` + + (files.length === 1 ? 'file' : 'files') + ' ' + + 'with you.', + }); + } + + serialize_result(); + res.send(result); +}; + Endpoint({ // "item" here means a filesystem node route: '/item-by-username', mw: [configurable_auth()], methods: ['POST'], - handler: handler_item_by_username, + handler: v0_1, +}).attach(router); + +Endpoint({ + // "item" here means a filesystem node + route: '/', + mw: [configurable_auth()], + methods: ['POST'], + handler: v0_2, }).attach(router); module.exports = app => { diff --git a/packages/backend/src/util/fnutil.js b/packages/backend/src/util/fnutil.js new file mode 100644 index 0000000000..ffcfef98ce --- /dev/null +++ b/packages/backend/src/util/fnutil.js @@ -0,0 +1,14 @@ +const UtilFn = fn => { + /** + * A null-coalescing call + */ + fn.if = function utilfn_if (v) { + if ( v === null || v === undefined ) return v; + return this(v); + } + return fn; +}; + +module.exports = { + UtilFn, +}; diff --git a/packages/backend/src/util/langutil.js b/packages/backend/src/util/langutil.js new file mode 100644 index 0000000000..f066023487 --- /dev/null +++ b/packages/backend/src/util/langutil.js @@ -0,0 +1,13 @@ +/** + * whatis is an alterative to typeof that reports what + * the type of the value actually is for real. + */ +const whatis = thing => { + if ( Array.isArray(thing) ) return 'array'; + if ( thing === null ) return 'null'; + return typeof thing; +}; + +module.exports = { + whatis, +}; diff --git a/packages/backend/src/util/workutil.js b/packages/backend/src/util/workutil.js new file mode 100644 index 0000000000..f76d1fd075 --- /dev/null +++ b/packages/backend/src/util/workutil.js @@ -0,0 +1,37 @@ +class WorkList { + constructor () { + this.locked_ = false; + this.items = []; + } + + list () { + return [...this.items]; + } + + clear_invalid () { + const new_items = []; + for ( let i=0 ; i < this.items.length ; i++ ) { + const item = this.items[i]; + if ( item.invalid ) continue; + new_items.push(item); + } + this.items = new_items; + } + + push (item) { + if ( this.locked_ ) { + throw new Error( + 'work items were already locked in; what are you doing?' + ); + } + this.items.push(item); + } + + lockin () { + this.locked_ = true; + } +} + +module.exports = { + WorkList, +};