refactor: share endpoint

This commit is contained in:
KernelDeimos 2024-06-25 16:51:14 -04:00 committed by Eric Dubé
parent 5551326c98
commit b78c83a4ab
7 changed files with 651 additions and 644 deletions

View File

@ -50,4 +50,10 @@ Comments beginning with `// track:`. See
This code manually creates a new "client-safe" version of
some object that's in scope. This could be either to pass
onto the browser or to pass to something like the
notification service.
notification service.
- `track: common operations on multiple items`
A patterm which emerges when multiple variables have
common operations done upon them in sequence.
It may be applicable to write an iterator in the
future, or something will come up that require
these to be handled with a modular approach instead.

View File

@ -109,7 +109,7 @@ class Sequence {
async run (values) {
// Initialize scope
values = values || this.thisArg?.values || {};
Object.assign(this.scope_, values); // TODO: can this be __proto__?
this.scope_.__proto__ = values;
// Run sequence
for ( ; this.i < this.steps.length ; this.i++ ) {
@ -138,7 +138,14 @@ class Sequence {
await this.sequence_.options_.before_each(this, step);
}
this.last_return_ = await step.fn(this);
this.last_return_ = await step.fn.call(
this.thisArg, this,
);
if ( this.sequence_.options_.after_each ) {
await this.sequence_.options_.after_each(this, step);
}
if ( this.stopped_ ) {
break;
}
@ -186,7 +193,13 @@ class Sequence {
this.scope_[k] = v;
}
values () {
values (opt_itemsToSet) {
if ( opt_itemsToSet ) {
for ( const k in opt_itemsToSet ) {
this.set(k, opt_itemsToSet[k]);
}
}
return new Proxy(this.scope_, {
get: (target, property) => {
if (property in target) {
@ -231,16 +244,21 @@ class Sequence {
}
}
const fn = async function () {
const fn = async function (opt_values) {
if ( opt_values && opt_values instanceof Sequence.SequenceState ) {
opt_values = opt_values.scope_;
}
const state = new Sequence.SequenceState(sequence, this);
await state.run();
await state.run(opt_values ?? undefined);
return state.last_return_;
}
this.steps_ = steps;
this.options_ = options || {};
Object.defineProperty(fn, 'name', { value: 'Sequence' });
Object.defineProperty(fn, 'name', {
value: options.name || 'Sequence'
});
Object.defineProperty(fn, 'sequence', { value: this });
return fn;

View File

@ -1,636 +0,0 @@
const express = require('express');
const { Endpoint } = require('../util/expressutil');
const validator = require('validator');
const APIError = require('../api/APIError');
const { get_user, get_app } = require('../helpers');
const { Context } = require('../util/context');
const config = require('../config');
const FSNodeParam = require('../api/filesystem/FSNodeParam');
const { TYPE_DIRECTORY } = require('../filesystem/FSNodeContext');
const { PermissionUtil } = require('../services/auth/PermissionService');
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 { DB_WRITE } = require('../services/database/consts');
const router = express.Router();
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 svc_share = req.services.get('share');
const lib_typeTagged = req.services.get('lib-type-tagged');
const actor = Context.get('actor');
const db = req.services.get('database').get('share', DB_WRITE);
// === 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_shares = UtilFn(shares => {
// Single-values get adapted into an array
if ( ! Array.isArray(shares) ) {
shares = [shares];
}
return shares;
})
// === Request Values ===
const strict_mode =
validate_mode.if(req.body.mode) ?? false;
const req_recipients =
validate_recipients.if(req.body.recipients) ?? [];
const req_shares =
validate_shares.if(req.body.shares) ?? [];
// === State Values ===
const recipients = [];
const result = {
// Metadata
$: 'api:share',
$version: 'v0.0.0',
// Results
status: null,
recipients: Array(req_recipients.length).fill(null),
shares: Array(req_shares.length).fill(null),
}
const recipients_work = new WorkList();
const shares_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.status = 'mixed';
result.recipients[i] = result.recipients[i].serialize();
}
}
for ( let i=0 ; i < result.shares.length ; i++ ) {
if ( ! result.shares[i] ) continue;
if ( result.shares[i] instanceof APIError ) {
result.status = 'mixed';
result.shares[i] = result.shares[i].serialize();
}
}
};
const strict_check = () =>{
if ( ! strict_mode ) return;
console.log('OK');
if (
result.recipients.some(v => v !== null) ||
result.shares.some(v => v !== null)
) {
console.log('DOESNT THIS??')
serialize_result();
result.status = 'aborted';
res.status(218).send(result);
console.log('HOWW???');
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();
// track: good candidate for sequence
// 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_or_email', null, {
value,
});
continue;
}
if ( value.match(config.username_regex) ) {
item.type = 'username';
continue;
}
if ( validator.isEmail(value) ) {
item.type = 'email';
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() ) {
const allowed_types = ['email', 'username'];
if ( ! allowed_types.includes(item.type) ) {
item.invalid = true;
result.recipients[item.i] =
APIError.create('disallowed_value', null, {
key: `recipients[${item.i}].type`,
allowed: allowed_types,
});
continue;
}
}
// Return: if there are invalid values in strict mode
recipients_work.clear_invalid();
for ( const item of recipients_work.list() ) {
if ( item.type !== 'email' ) continue;
const errors = [];
if ( ! validator.isEmail(item.value) ) {
errors.push('`email` is not valid');
}
if ( errors.length ) {
item.invalid = true;
result.recipients[item.i] =
APIError.create('field_errors', null, {
key: `recipients[${item.i}]`,
errors,
});
continue;
}
}
recipients_work.clear_invalid();
// CHECK EXISTING USERS FOR EMAIL SHARES
for ( const recipient_item of recipients_work.list() ) {
if ( recipient_item.type !== 'email' ) continue;
const user = await get_user({
email: recipient_item.value,
});
if ( ! user ) continue;
recipient_item.type = 'username';
recipient_item.value = user.username;
}
recipients_work.clear_invalid();
// Check: users specified by username exist
for ( const item of recipients_work.list() ) {
if ( item.type !== 'username' ) continue;
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_shares.length < 1 ) {
throw APIError.create('field_invalid', null, {
key: 'shares',
expected: 'at least one',
got: 'none',
})
}
for ( let i=0 ; i < req_shares.length ; i++ ) {
const value = req_shares[i];
shares_work.push({ i, value });
}
shares_work.lockin();
// Check: all share items are a type-tagged-object
// with one of these types: fs-share, app-share.
for ( const item of shares_work.list() ) {
const { i } = item;
let { value } = item;
const thing = lib_typeTagged.process(value);
if ( thing.$ === 'error' ) {
item.invalid = true;
result.shares[i] =
APIError.create('format_error', null, {
message: thing.message
});
continue;
}
const allowed_things = ['fs-share', 'app-share'];
if ( ! allowed_things.includes(thing.$) ) {
item.invalid = true;
result.shares[i] =
APIError.create('disallowed_thing', null, {
thing: thing.$,
accepted: allowed_things,
});
continue;
}
item.thing = thing;
}
shares_work.clear_invalid();
// Process: create $share-intent:file for file items
for ( const item of shares_work.list() ) {
const { thing } = item;
if ( thing.$ !== 'fs-share' ) continue;
item.type = 'fs';
const errors = [];
if ( ! thing.path ) {
errors.push('`path` is required');
}
let access = thing.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.shares[item.i] =
APIError.create('field_errors', null, {
key: `shares[${item.i}]`,
errors
});
continue;
}
item.path = thing.path;
item.share_intent = {
$: 'share-intent:file',
permissions: [PermissionUtil.join('fs', thing.path, access)],
};
}
shares_work.clear_invalid();
// Process: create $share-intent:app for app items
for ( const item of shares_work.list() ) {
const { thing } = item;
if ( thing.$ !== 'app-share' ) continue;
item.type = 'app';
const errors = [];
if ( ! thing.uid && ! thing.name ) {
errors.push('`uid` or `name` is required');
}
if ( errors.length ) {
item.invalid = true;
result.shares[item.i] =
APIError.create('field_errors', null, {
key: `shares[${item.i}]`,
errors
});
continue;
}
const app_selector = thing.uid
? `uid#${thing.uid}` : thing.name;
item.share_intent = {
$: 'share-intent:app',
permissions: [
PermissionUtil.join('app', app_selector, 'access')
]
}
continue;
}
shares_work.clear_invalid();
for ( const item of shares_work.list() ) {
if ( item.type !== 'fs' ) continue;
const node = await (new FSNodeParam('path')).consolidate({
req, getParam: () => item.path
});
if ( ! await node.exists() ) {
item.invalid = true;
result.shares[item.i] = APIError.create('subject_does_not_exist', {
path: item.path,
})
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;
}
shares_work.clear_invalid();
// Fetch app info for app shares
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
const { thing } = item;
const app = await get_app(thing.uid ?
{ uid: thing.uid } : { name: thing.name });
if ( ! app ) {
item.invalid = true;
result.shares[item.i] =
// note: since we're reporting `entity_not_found`
// we will report the id as an entity-storage-compatible
// identifier.
APIError.create('entity_not_found', null, {
identifier: thing.uid
? { uid: thing.uid }
: { id: { name: thing.name } }
});
}
app.metadata = db.case({
mysql: () => app.metadata,
otherwise: () => JSON.parse(app.metadata ?? '{}')
})();
item.app = app;
}
shares_work.clear_invalid();
// Process: conditionally add permission for subdomain
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
const [subdomain] = await db.read(
`SELECT * FROM subdomains WHERE associated_app_id = ? ` +
`AND user_id = ? LIMIT 1`,
[item.app.id, actor.type.user.id]
);
if ( ! subdomain ) continue;
// The subdomain is also owned by this user, so we'll
// add a permission for that as well
const site_selector = `uid#${subdomain.uuid}`;
item.share_intent.permissions.push(
PermissionUtil.join('site', site_selector, 'access')
)
}
// Process: conditionally add permission for AppData
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
if ( ! item.app.metadata?.shared_appdata ) continue;
const app_owner = await get_user({ id: item.app.owner_user_id });
const appdatadir =
`/${app_owner.username}/AppData/${item.app.uid}`;
const appdatadir_perm =
PermissionUtil.join('fs', appdatadir, 'write');
item.share_intent.permissions.push(appdatadir_perm);
}
shares_work.clear_invalid();
// Mark files as successful; further errors will be
// reported on recipients instead.
for ( const item of shares_work.list() ) {
result.shares[item.i] =
{
$: 'api:status-report',
status: 'success',
fields: {
permission: item.permission,
}
};
}
if ( strict_check() ) return;
if ( req.body.dry_run ) {
// Mark everything left as successful
for ( const item of recipients_work.list() ) {
result.recipients[item.i] =
{ $: 'api:status-report', status: 'success' };
}
result.status = 'success';
result.dry_run = true;
serialize_result();
res.send(result);
return;
}
for ( const recipient_item of recipients_work.list() ) {
if ( recipient_item.type !== 'username' ) continue;
const username = recipient_item.user.username;
for ( const share_item of shares_work.list() ) {
const permissions = share_item.share_intent.permissions;
for ( const perm of permissions ) {
await svc_permission.grant_user_user_permission(
actor,
username,
perm,
);
}
}
// 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 item of shares_work.list() ) {
if ( item.type !== 'file' ) continue;
files.push(
await item.node.getSafeEntry(),
);
}
}
const apps = []; {
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
// TODO: is there a general way to create a
// client-safe app right now without
// going through entity storage?
// track: manual safe object
apps.push(item.name
? item.name : await get_app({
uid: item.uid,
}));
}
}
svc_notification.notify(UsernameNotifSelector(username), {
source: 'sharing',
icon: 'shared.svg',
title: 'Files were shared with you!',
template: 'file-shared-with-you',
fields: {
username: actor.type.user.username,
files,
},
text: `The user ${quot(req.user.username)} shared ` +
`${files.length} ` +
(files.length === 1 ? 'file' : 'files') + ' ' +
'with you.',
});
result.recipients[recipient_item.i] =
{ $: 'api:status-report', status: 'success' };
}
for ( const recipient_item of recipients_work.list() ) {
if ( recipient_item.type !== 'email' ) continue;
const email = recipient_item.value;
// data that gets stored in the `data` column of the share
const data = {
$: 'internal:share',
$v: 'v0.0.0',
permissions: [],
};
for ( const share_item of shares_work.list() ) {
const permissions = share_item.share_intent.permissions;
data.permissions.push(...permissions);
}
// track: scoping iife
const share_token = await (async () => {
const share_uid = await svc_share.create_share({
issuer: actor,
email,
data,
});
return svc_token.sign('share', {
$: 'token:share',
$v: '0.0.0',
uid: share_uid,
}, {
expiresIn: '14d'
});
})();
const email_link =
`${config.origin}?share_token=${share_token}`;
await svc_email.send_email({ email }, 'share_by_email', {
link: email_link,
});
}
result.status = 'success';
serialize_result(); // might change result.status to 'mixed'
res.send(result);
};
Endpoint({
// "item" here means a filesystem node
route: '/',
mw: [configurable_auth()],
methods: ['POST'],
handler: v0_2,
}).attach(router);
module.exports = app => {
app.use('/share', router);
};

View File

@ -76,7 +76,6 @@ class PuterAPIService extends BaseService {
app.use(require('../routers/healthcheck'))
app.use(require('../routers/test'))
app.use(require('../routers/update-taskbar-items'))
require('../routers/share')(app);
require('../routers/whoami')(app);
}

View File

@ -1,6 +1,7 @@
const APIError = require("../api/APIError");
const { get_user } = require("../helpers");
const configurable_auth = require("../middleware/configurable_auth");
const { Context } = require("../util/context");
const { Endpoint } = require("../util/expressutil");
const { whatis } = require("../util/langutil");
const { Actor, UserActorType } = require("./auth/Actor");
@ -20,6 +21,11 @@ class ShareService extends BaseService {
}
['__on_install.routes'] (_, { app }) {
this.install_sharelink_endpoints({ app });
this.install_share_endpoint({ app });
}
install_sharelink_endpoints ({ app }) {
// track: scoping iife
const router = (() => {
const require = this.require;
@ -208,6 +214,30 @@ class ShareService extends BaseService {
}).attach(router);
}
install_share_endpoint ({ app }) {
// track: scoping iife
const router = (() => {
const require = this.require;
const express = require('express');
return express.Router();
})();
app.use('/share', router);
const share_sequence = require('../structured/sequence/share.js');
Endpoint({
route: '/',
methods: ['POST'],
mw: [configurable_auth()],
handler: async (req, res) => {
const actor = Actor.adapt(req.user);
return await share_sequence.call(this, {
actor, req, res,
});
}
}).attach(router);
}
async get_share ({ uid }) {
const [share] = await this.db.read(
'SELECT * FROM share WHERE uid = ?',

View File

@ -0,0 +1,6 @@
# Structured Code
Each directory in this directory represents some type of
structured code. For example, everything in the directory
`./sequence` (relative to this file's location) is a
cjs module that exports an instance of [Sequence](../codex/Sequence.js).

View File

@ -0,0 +1,584 @@
const APIError = require("../../api/APIError");
const { Sequence } = require("../../codex/Sequence");
const config = require("../../config");
const { WorkList } = require("../../util/workutil");
const validator = require('validator');
const { get_user, get_app } = require("../../helpers");
const { PermissionUtil } = require("../../services/auth/PermissionService");
const FSNodeParam = require("../../api/filesystem/FSNodeParam");
const { TYPE_DIRECTORY } = require("../../filesystem/FSNodeContext");
const { UsernameNotifSelector } = require("../../services/NotificationService");
const { quot } = require("../../util/strutil");
/*
This code is optimized for editors supporting folding.
Fold at Level 2 to conveniently browse sequence steps.
Fold at Level 3 after opening an inner-sequence.
If you're using VSCode {
typically "Ctrl+K, Ctrl+2" or "⌘K, ⌘2";
to revert "Ctrl+K, Ctrl+J" or "⌘K, ⌘J";
https://stackoverflow.com/questions/30067767
}
*/
module.exports = new Sequence([
function validate_mode (a) {
const req = a.get('req');
const mode = req.body.mode;
if ( mode === 'strict' ) {
a.set('strict_mode', true);
return;
}
if ( ! mode || mode === 'best-effort' ) {
a.set('strict_mode', false);
return;
}
throw APIError.create('field_invalid', null, {
key: 'mode',
expected: '`strict`, `best-effort`, or undefined',
});
},
function validate_recipients (a) {
const req = a.get('req');
let recipients = req.body.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,
})
}
// At least one recipient
if ( recipients.length < 1 ) {
throw APIError.create('field_invalid', null, {
key: 'recipients',
expected: 'at least one',
got: 'none',
});
}
a.set('req_recipients', recipients);
},
function validate_shares (a) {
const req = a.get('req');
let shares = req.body.shares;
if ( ! Array.isArray(shares) ) {
shares = [shares];
}
// At least one share
if ( shares.length < 1 ) {
throw APIError.create('field_invalid', null, {
key: 'shares',
expected: 'at least one',
got: 'none',
});
}
a.set('req_shares', shares);
},
function initialize_result_object (a) {
a.set('result', {
$: 'api:share',
$version: 'v0.0.0',
status: null,
recipients:
Array(a.get('req_recipients').length).fill(null),
shares:
Array(a.get('req_shares').length).fill(null),
serialize () {
const result = this;
for ( let i=0 ; i < result.recipients.length ; i++ ) {
if ( ! result.recipients[i] ) continue;
if ( result.recipients[i] instanceof APIError ) {
result.status = 'mixed';
result.recipients[i] = result.recipients[i].serialize();
}
}
for ( let i=0 ; i < result.shares.length ; i++ ) {
if ( ! result.shares[i] ) continue;
if ( result.shares[i] instanceof APIError ) {
result.status = 'mixed';
result.shares[i] = result.shares[i].serialize();
}
}
delete result.serialize;
return result;
}
});
},
function initialize_worklists (a) {
const recipients_work = new WorkList();
const shares_work = new WorkList();
const { req_recipients, req_shares } = a.values();
// track: common operations on multiple items
for ( let i=0 ; i < req_recipients.length ; i++ ) {
const value = req_recipients[i];
recipients_work.push({ i, value });
}
for ( let i=0 ; i < req_shares.length ; i++ ) {
const value = req_shares[i];
shares_work.push({ i, value });
}
recipients_work.lockin();
shares_work.lockin();
a.values({ recipients_work, shares_work });
},
new Sequence({ name: 'process recipients',
after_each (a) {
const { recipients_work } = a.values();
recipients_work.clear_invalid();
}
}, [
function valid_username_or_email (a) {
const { result, recipients_work } = a.values();
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_or_email', null, {
value,
});
continue;
}
if ( value.match(config.username_regex) ) {
item.type = 'username';
continue;
}
if ( validator.isEmail(value) ) {
item.type = 'email';
continue;
}
item.invalid = true;
result.recipients[i] =
APIError.create('invalid_username_or_email', null, {
value,
});
}
},
async function check_existing_users_for_email_shares (a) {
const { recipients_work } = a.values();
for ( const recipient_item of recipients_work.list() ) {
if ( recipient_item.type !== 'email' ) continue;
const user = await get_user({
email: recipient_item.value,
});
if ( ! user ) continue;
recipient_item.type = 'username';
recipient_item.value = user.username;
}
},
async function check_username_specified_users_exist (a) {
const { result, recipients_work } = a.values();
for ( const item of recipients_work.list() ) {
if ( item.type !== 'username' ) continue;
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;
}
}
]),
new Sequence({ name: 'process shares',
beforeEach (a) {
const { shares_work } = a.values();
shares_work.clear_invalid();
}
}, [
function validate_share_types (a) {
const { result, shares_work } = a.values();
const lib_typeTagged = a.iget('services').get('lib-type-tagged');
for ( const item of shares_work.list() ) {
const { i } = item;
let { value } = item;
const thing = lib_typeTagged.process(value);
if ( thing.$ === 'error' ) {
item.invalid = true;
result.shares[i] =
APIError.create('format_error', null, {
message: thing.message
});
continue;
}
const allowed_things = ['fs-share', 'app-share'];
if ( ! allowed_things.includes(thing.$) ) {
item.invalid = true;
result.shares[i] =
APIError.create('disallowed_thing', null, {
thing: thing.$,
accepted: allowed_things,
});
continue;
}
item.thing = thing;
}
},
function create_file_share_intents (a) {
const { result, shares_work } = a.values();
for ( const item of shares_work.list() ) {
const { thing } = item;
if ( thing.$ !== 'fs-share' ) continue;
item.type = 'fs';
const errors = [];
if ( ! thing.path ) {
errors.push('`path` is required');
}
let access = thing.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.shares[item.i] =
APIError.create('field_errors', null, {
key: `shares[${item.i}]`,
errors
});
continue;
}
item.path = thing.path;
item.share_intent = {
$: 'share-intent:file',
permissions: [PermissionUtil.join('fs', thing.path, access)],
};
}
},
function create_app_share_intents (a) {
const { result, shares_work } = a.values();
for ( const item of shares_work.list() ) {
const { thing } = item;
if ( thing.$ !== 'app-share' ) continue;
item.type = 'app';
const errors = [];
if ( ! thing.uid && ! thing.name ) {
errors.push('`uid` or `name` is required');
}
if ( errors.length ) {
item.invalid = true;
result.shares[item.i] =
APIError.create('field_errors', null, {
key: `shares[${item.i}]`,
errors
});
continue;
}
const app_selector = thing.uid
? `uid#${thing.uid}` : thing.name;
item.share_intent = {
$: 'share-intent:app',
permissions: [
PermissionUtil.join('app', app_selector, 'access')
]
}
continue;
}
},
async function fetch_nodes_for_file_shares (a) {
const { req, result, shares_work } = a.values();
for ( const item of shares_work.list() ) {
if ( item.type !== 'fs' ) continue;
const node = await (new FSNodeParam('path')).consolidate({
req, getParam: () => item.path
});
if ( ! await node.exists() ) {
item.invalid = true;
result.shares[item.i] = APIError.create('subject_does_not_exist', {
path: item.path,
})
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;
}
},
async function fetch_apps_for_app_shares (a) {
const { result, shares_work } = a.values();
const db = a.iget('db');
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
const { thing } = item;
const app = await get_app(thing.uid ?
{ uid: thing.uid } : { name: thing.name });
if ( ! app ) {
item.invalid = true;
result.shares[item.i] =
// note: since we're reporting `entity_not_found`
// we will report the id as an entity-storage-compatible
// identifier.
APIError.create('entity_not_found', null, {
identifier: thing.uid
? { uid: thing.uid }
: { id: { name: thing.name } }
});
}
app.metadata = db.case({
mysql: () => app.metadata,
otherwise: () => JSON.parse(app.metadata ?? '{}')
})();
item.app = app;
}
},
async function add_subdomain_permissions (a) {
const { shares_work } = a.values();
const actor = a.get('actor');
const db = a.iget('db');
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
const [subdomain] = await db.read(
`SELECT * FROM subdomains WHERE associated_app_id = ? ` +
`AND user_id = ? LIMIT 1`,
[item.app.id, actor.type.user.id]
);
if ( ! subdomain ) continue;
// The subdomain is also owned by this user, so we'll
// add a permission for that as well
const site_selector = `uid#${subdomain.uuid}`;
item.share_intent.permissions.push(
PermissionUtil.join('site', site_selector, 'access')
)
}
},
async function add_appdata_permissions (a) {
const { result, shares_work } = a.values();
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
if ( ! item.app.metadata?.shared_appdata ) continue;
const app_owner = await get_user({ id: item.app.owner_user_id });
const appdatadir =
`/${app_owner.username}/AppData/${item.app.uid}`;
const appdatadir_perm =
PermissionUtil.join('fs', appdatadir, 'write');
item.share_intent.permissions.push(appdatadir_perm);
}
},
function apply_success_status_to_shares (a) {
const { result, shares_work } = a.values();
for ( const item of shares_work.list() ) {
result.shares[item.i] =
{
$: 'api:status-report',
status: 'success',
fields: {
permission: item.permission,
}
};
}
},
]),
function abort_on_error_if_mode_is_strict (a) {
const strict_mode = a.get('strict_mode');
if ( ! strict_mode ) return;
const result = a.get('result');
if (
result.recipients.some(v => v !== null) ||
result.shares.some(v => v !== null)
) {
result.serialize();
result.status = 'aborted';
const res = a.get('res');
res.status(218).send(result);
a.stop();
}
},
function early_return_on_dry_run (a) {
if ( ! a.get('req').body.dry_run ) return;
const { res, result, recipients_work } = a.values();
for ( const item of recipients_work.list() ) {
result.recipients[item.i] =
{ $: 'api:status-report', status: 'success' };
}
result.serialize();
result.status = 'success';
result.dry_run = true;
res.send(result);
a.stop();
},
async function grant_permissions_to_existing_users (a) {
const {
req, result, recipients_work, shares_work
} = a.values();
const svc_permission = a.iget('services').get('permission');
const svc_notification = a.iget('services').get('notification');
const actor = a.get('actor');
for ( const recipient_item of recipients_work.list() ) {
if ( recipient_item.type !== 'username' ) continue;
const username = recipient_item.user.username;
for ( const share_item of shares_work.list() ) {
const permissions = share_item.share_intent.permissions;
for ( const perm of permissions ) {
await svc_permission.grant_user_user_permission(
actor,
username,
perm,
);
}
}
const files = []; {
for ( const item of shares_work.list() ) {
if ( item.type !== 'file' ) continue;
files.push(
await item.node.getSafeEntry(),
);
}
}
const apps = []; {
for ( const item of shares_work.list() ) {
if ( item.type !== 'app' ) continue;
// TODO: is there a general way to create a
// client-safe app right now without
// going through entity storage?
// track: manual safe object
apps.push(item.name
? item.name : await get_app({
uid: item.uid,
}));
}
}
svc_notification.notify(UsernameNotifSelector(username), {
source: 'sharing',
icon: 'shared.svg',
title: 'Files were shared with you!',
template: 'file-shared-with-you',
fields: {
username: actor.type.user.username,
files,
},
text: `The user ${quot(req.user.username)} shared ` +
`${files.length} ` +
(files.length === 1 ? 'file' : 'files') + ' ' +
'with you.',
});
result.recipients[recipient_item.i] =
{ $: 'api:status-report', status: 'success' };
}
},
async function email_the_email_recipients (a) {
const { actor, recipients_work, shares_work } = a.values();
const svc_share = a.iget('services').get('share');
const svc_token = a.iget('services').get('token');
const svc_email = a.iget('services').get('token');
for ( const recipient_item of recipients_work.list() ) {
if ( recipient_item.type !== 'email' ) continue;
const email = recipient_item.value;
// data that gets stored in the `data` column of the share
const data = {
$: 'internal:share',
$v: 'v0.0.0',
permissions: [],
};
for ( const share_item of shares_work.list() ) {
const permissions = share_item.share_intent.permissions;
data.permissions.push(...permissions);
}
// track: scoping iife
const share_token = await (async () => {
const share_uid = await svc_share.create_share({
issuer: actor,
email,
data,
});
return svc_token.sign('share', {
$: 'token:share',
$v: '0.0.0',
uid: share_uid,
}, {
expiresIn: '14d'
});
})();
const email_link =
`${config.origin}?share_token=${share_token}`;
await svc_email.send_email({ email }, 'share_by_email', {
link: email_link,
});
}
},
function send_result (a) {
const { res, result } = a.values();
result.serialize();
res.send(result);
}
]);