diff --git a/doc/devmeta/track-comments.md b/doc/devmeta/track-comments.md index 94233250..1c373262 100644 --- a/doc/devmeta/track-comments.md +++ b/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. \ No newline at end of file + 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. diff --git a/packages/backend/src/codex/Sequence.js b/packages/backend/src/codex/Sequence.js index 4a8b2c51..f20a58ac 100644 --- a/packages/backend/src/codex/Sequence.js +++ b/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; diff --git a/packages/backend/src/routers/share.js b/packages/backend/src/routers/share.js deleted file mode 100644 index 55bd3d14..00000000 --- a/packages/backend/src/routers/share.js +++ /dev/null @@ -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); -}; diff --git a/packages/backend/src/services/PuterAPIService.js b/packages/backend/src/services/PuterAPIService.js index 7657cc76..f8191a32 100644 --- a/packages/backend/src/services/PuterAPIService.js +++ b/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); } diff --git a/packages/backend/src/services/ShareService.js b/packages/backend/src/services/ShareService.js index f7b6a6b9..bc67138a 100644 --- a/packages/backend/src/services/ShareService.js +++ b/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 = ?', diff --git a/packages/backend/src/structured/README.md b/packages/backend/src/structured/README.md new file mode 100644 index 00000000..07d6673e --- /dev/null +++ b/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). diff --git a/packages/backend/src/structured/sequence/share.js b/packages/backend/src/structured/sequence/share.js new file mode 100644 index 00000000..2cc889cc --- /dev/null +++ b/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); + } +]);