Browse Source

feat: add share service and share-by-email to /share

KernelDeimos 11 tháng trước cách đây
mục cha
commit
db5990a989

+ 12 - 0
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.

+ 3 - 0
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 }) => {

+ 6 - 1
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,

+ 87 - 10
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';

+ 1 - 0
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,

+ 4 - 0
packages/backend/src/services/EmailService.js

@@ -141,6 +141,10 @@ If this was not you, please contact support@puter.com immediately.
         <p>Puter</p>
         `
     },
+    'share_by_email': {
+        subject: 'share by email',
+        html: `testing: {{link}}`
+    },
 }
 
 class Emailservice extends BaseService {

+ 1 - 1
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;

+ 65 - 0
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,
+};
+

+ 6 - 1
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}`);

+ 11 - 0
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
+);