Procházet zdrojové kódy

refactor: share endpoint

KernelDeimos před 11 měsíci
rodič
revize
b78c83a4ab

+ 7 - 1
doc/devmeta/track-comments.md

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

+ 24 - 6
packages/backend/src/codex/Sequence.js

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

+ 0 - 636
packages/backend/src/routers/share.js

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

+ 0 - 1
packages/backend/src/services/PuterAPIService.js

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

+ 30 - 0
packages/backend/src/services/ShareService.js

@@ -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 = ?',

+ 6 - 0
packages/backend/src/structured/README.md

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

+ 584 - 0
packages/backend/src/structured/sequence/share.js

@@ -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);
+    }
+]);