Skip to content

Commit

Permalink
feat: multi-recipient multi-file share endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
KernelDeimos committed Jun 17, 2024
1 parent afe37a6 commit 846fdc2
Show file tree
Hide file tree
Showing 5 changed files with 423 additions and 2 deletions.
21 changes: 21 additions & 0 deletions packages/backend/src/api/APIError.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
340 changes: 338 additions & 2 deletions packages/backend/src/routers/share.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 => {
Expand Down
Loading

0 comments on commit 846fdc2

Please sign in to comment.