mirror of
https://github.com/HeyPuter/puter.git
synced 2026-01-08 06:00:28 -06:00
feat: multi-recipient multi-file share endpoint
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
14
packages/backend/src/util/fnutil.js
Normal file
14
packages/backend/src/util/fnutil.js
Normal 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,
|
||||
};
|
||||
13
packages/backend/src/util/langutil.js
Normal file
13
packages/backend/src/util/langutil.js
Normal 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,
|
||||
};
|
||||
37
packages/backend/src/util/workutil.js
Normal file
37
packages/backend/src/util/workutil.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user