From db5990a98935817c0e16d30e921bb99c57a98fc8 Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Thu, 20 Jun 2024 19:36:10 -0400 Subject: [PATCH] feat: add share service and share-by-email to /share --- doc/devmeta/track-comments.md | 12 +++ packages/backend/src/CoreModule.js | 3 + packages/backend/src/api/APIError.js | 7 +- packages/backend/src/routers/share.js | 97 +++++++++++++++++-- packages/backend/src/routers/whoami.js | 1 + packages/backend/src/services/EmailService.js | 4 + .../backend/src/services/GetUserService.js | 2 +- packages/backend/src/services/ShareService.js | 65 +++++++++++++ .../database/SqliteDatabaseAccessService.js | 7 +- .../database/sqlite_setup/0014_share.sql | 11 +++ 10 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 packages/backend/src/services/ShareService.js create mode 100644 packages/backend/src/services/database/sqlite_setup/0014_share.sql diff --git a/doc/devmeta/track-comments.md b/doc/devmeta/track-comments.md index 5244284d7..916d45810 100644 --- a/doc/devmeta/track-comments.md +++ b/doc/devmeta/track-comments.md @@ -7,6 +7,8 @@ Comments beginning with `// track:`. See - `track: type check`: A condition that's used to check the type of an imput. +- `track: adapt` + A value can by adapted from another type at this line. - `track: bounds check`: A condition that's used to check the bounds of an array or other list-like entity. @@ -22,3 +24,13 @@ Comments beginning with `// track:`. See A common pattern where a prefix string is "sliced off" of another string to obtain a significant value, such as an indentifier. +- `track: actor type` + The sub-type of an Actor object is checked. +- `track: scoping iife` + An immediately-invoked function expression specifically + used to reduce scope clutter. +- `track: good candidate for sequence` + Some code involves a series of similar steps, + or there's a common behavior that should happen + in between. The Sequence class is good for this so + it might be a worthy migration. diff --git a/packages/backend/src/CoreModule.js b/packages/backend/src/CoreModule.js index 6289cfe6d..2594463cc 100644 --- a/packages/backend/src/CoreModule.js +++ b/packages/backend/src/CoreModule.js @@ -283,6 +283,9 @@ const install = async ({ services, app, useapi }) => { const { ProtectedAppService } = require('./services/ProtectedAppService'); services.registerService('__protected-app', ProtectedAppService); + + const { ShareService } = require('./services/ShareService'); + services.registerService('share', ShareService); } const install_legacy = async ({ services }) => { diff --git a/packages/backend/src/api/APIError.js b/packages/backend/src/api/APIError.js index eeab2f3a9..e2b99a984 100644 --- a/packages/backend/src/api/APIError.js +++ b/packages/backend/src/api/APIError.js @@ -41,7 +41,12 @@ module.exports = class APIError { status: 400, message: ({ message }) => `error: ${message}`, }, - + 'disallowed_value': { + status: 400, + message: ({ key ,allowed }) => + `value of ${quot(key)} must be one of: ` + + allowed.map(v => quot(v)).join(', ') + }, // Things 'disallowed_thing': { status: 400, diff --git a/packages/backend/src/routers/share.js b/packages/backend/src/routers/share.js index f55181243..df75cb439 100644 --- a/packages/backend/src/routers/share.js +++ b/packages/backend/src/routers/share.js @@ -23,6 +23,7 @@ const v0_2 = async (req, res) => { 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'); @@ -148,6 +149,8 @@ const v0_2 = async (req, res) => { } 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; @@ -155,9 +158,10 @@ const v0_2 = async (req, res) => { if ( typeof value !== 'string' ) { item.invalid = true; result.recipients[i] = - APIError.create('invalid_username_of_email', null, { + APIError.create('invalid_username_or_email', null, { value, - }) + }); + continue; } if ( value.match(config.username_regex) ) { @@ -165,7 +169,7 @@ const v0_2 = async (req, res) => { continue; } if ( validator.isEmail(value) ) { - item.type = 'username'; + item.type = 'email'; continue; } @@ -182,12 +186,13 @@ const v0_2 = async (req, res) => { // Expect: no emails specified yet // AND usernames exist for ( const item of recipients_work.list() ) { - if ( item.type === 'email' ) { + const allowed_types = ['email', 'username']; + if ( ! allowed_types.includes(item.type) ) { item.invalid = true; result.recipients[item.i] = - APIError.create('future', null, { - what: 'specifying recipients by email', - value: item.value + APIError.create('disallowed_value', null, { + key: `recipients[${item.i}].type`, + allowed: allowed_types, }); continue; } @@ -195,8 +200,44 @@ const v0_2 = async (req, res) => { // 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(); 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; @@ -243,8 +284,6 @@ const v0_2 = async (req, res) => { continue; } - console.log('thing?', thing); - const allowed_things = ['fs-share', 'app-share']; if ( ! allowed_things.includes(thing.$) ) { APIError.create('disallowed_thing', null, { @@ -417,7 +456,45 @@ const v0_2 = async (req, res) => { }); result.recipients[recipient_item.i] = - { $: 'api:status-report', statis: 'success' }; + { $: '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() ) { + data.permissions.push(share_item.permission); + } + + // 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: 'v0.0.0', + uid: share_uid, + }); + })(); + + const email_link = config.origin + + `/sharelink?token=${share_token}`; + + await svc_email.send_email({ email }, 'share_by_email', { + link: email_link, + }); } result.status = 'success'; diff --git a/packages/backend/src/routers/whoami.js b/packages/backend/src/routers/whoami.js index 47c96e10d..a08d796ee 100644 --- a/packages/backend/src/routers/whoami.js +++ b/packages/backend/src/routers/whoami.js @@ -46,6 +46,7 @@ const WHOAMI_GET = eggspress('/whoami', { username: req.user.username, uuid: req.user.uuid, email: req.user.email, + unconfirmed_email: req.user.email, email_confirmed: req.user.email_confirmed, requires_email_confirmation: req.user.requires_email_confirmation, desktop_bg_url: req.user.desktop_bg_url, diff --git a/packages/backend/src/services/EmailService.js b/packages/backend/src/services/EmailService.js index a0859d96a..6f03e70ba 100644 --- a/packages/backend/src/services/EmailService.js +++ b/packages/backend/src/services/EmailService.js @@ -141,6 +141,10 @@ If this was not you, please contact support@puter.com immediately.

Puter

` }, + 'share_by_email': { + subject: 'share by email', + html: `testing: {{link}}` + }, } class Emailservice extends BaseService { diff --git a/packages/backend/src/services/GetUserService.js b/packages/backend/src/services/GetUserService.js index 802573aa6..ffe3d02b9 100644 --- a/packages/backend/src/services/GetUserService.js +++ b/packages/backend/src/services/GetUserService.js @@ -28,7 +28,7 @@ class GetUserService extends BaseService { async get_user (options) { const user = await this.get_user_(options); if ( ! user ) return null; - + const svc_whoami = this.services.get('whoami'); await svc_whoami.get_details({ user }, user); return user; diff --git a/packages/backend/src/services/ShareService.js b/packages/backend/src/services/ShareService.js new file mode 100644 index 000000000..ca07b9678 --- /dev/null +++ b/packages/backend/src/services/ShareService.js @@ -0,0 +1,65 @@ +const { whatis } = require("../util/langutil"); +const { Actor, UserActorType } = require("./auth/Actor"); +const BaseService = require("./BaseService"); +const { DB_WRITE } = require("./database/consts"); + +class ShareService extends BaseService { + static MODULES = { + uuidv4: require('uuid').v4, + validator: require('validator'), + }; + + async _init () { + this.db = await this.services.get('database').get(DB_WRITE, 'share'); + } + + async create_share ({ + issuer, + email, + data, + }) { + const require = this.require; + const validator = require('validator'); + + // track: type check + if ( typeof email !== 'string' ) { + throw new Error('email must be a string'); + } + // track: type check + if ( whatis(data) !== 'object' ) { + throw new Error('data must be an object'); + } + + // track: adapt + issuer = Actor.adapt(issuer); + // track: type check + if ( ! (issuer instanceof Actor) ) { + throw new Error('expected issuer to be Actor'); + } + + // track: actor type + if ( ! (issuer.type instanceof UserActorType) ) { + throw new Error('only users are allowed to create shares'); + } + + if ( ! validator.isEmail(email) ) { + throw new Error('invalid email'); + } + + const uuid = this.modules.uuidv4(); + + await this.db.write( + 'INSERT INTO `share` ' + + '(`uid`, `issuer_user_id`, `recipient_email`, `data`) ' + + 'VALUES (?, ?, ?, ?)', + [uuid, issuer.type.user.id, email, JSON.stringify(data)] + ); + + return uuid; + } +} + +module.exports = { + ShareService, +}; + diff --git a/packages/backend/src/services/database/SqliteDatabaseAccessService.js b/packages/backend/src/services/database/SqliteDatabaseAccessService.js index b642b9c3d..6f66fa58b 100644 --- a/packages/backend/src/services/database/SqliteDatabaseAccessService.js +++ b/packages/backend/src/services/database/SqliteDatabaseAccessService.js @@ -42,7 +42,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { this.db = new Database(this.config.path); // Database upgrade logic - const TARGET_VERSION = 11; + const TARGET_VERSION = 12; if ( do_setup ) { this.log.noticeme(`SETUP: creating database at ${this.config.path}`); @@ -60,6 +60,7 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { '0011_notification.sql', '0012_appmetadata.sql', '0013_protected-apps.sql', + '0014_share.sql', ].map(p => path_.join(__dirname, 'sqlite_setup', p)); const fs = require('fs'); for ( const filename of sql_files ) { @@ -120,6 +121,10 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { upgrade_files.push('0013_protected-apps.sql'); } + if ( user_version <= 11 ) { + upgrade_files.push('0014_share.sql'); + } + if ( upgrade_files.length > 0 ) { this.log.noticeme(`Database out of date: ${this.config.path}`); this.log.noticeme(`UPGRADING DATABASE: ${user_version} -> ${TARGET_VERSION}`); diff --git a/packages/backend/src/services/database/sqlite_setup/0014_share.sql b/packages/backend/src/services/database/sqlite_setup/0014_share.sql new file mode 100644 index 000000000..2b1b76895 --- /dev/null +++ b/packages/backend/src/services/database/sqlite_setup/0014_share.sql @@ -0,0 +1,11 @@ +CREATE TABLE `share` ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "uid" TEXT NOT NULL UNIQUE, + "issuer_user_id" INTEGER NOT NULL, + "recipient_email" TEXT NOT NULL, + "data" JSON DEFAULT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY ("issuer_user_id") REFERENCES "user" ("id") + ON DELETE CASCADE ON UPDATE CASCADE +);