feat: multi-recipient multi-file share endpoint

This commit is contained in:
KernelDeimos
2024-06-17 17:04:40 -04:00
committed by Eric Dubé
parent afe37a69d6
commit 846fdc20d4
5 changed files with 423 additions and 2 deletions

View File

@@ -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

View File

@@ -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 => {

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};