Jelajahi Sumber

Add locking to save_account

KernelDeimos 1 tahun lalu
induk
melakukan
691c8f1436

+ 3 - 0
packages/backend/src/CoreModule.js

@@ -210,6 +210,9 @@ const install = async ({ services, app }) => {
 
     const { AntiCSRFService } = require('./services/auth/AntiCSRFService');
     services.registerService('anti-csrf', AntiCSRFService);
+
+    const { LockService } = require('./services/LockService');
+    services.registerService('lock', LockService);
 }
 
 const install_legacy = async ({ services }) => {

+ 118 - 109
packages/backend/src/routers/save_account.js

@@ -75,123 +75,132 @@ router.post('/save_account', auth, express.json(), async (req, res, next)=>{
         return res.status(429).send('Too many requests.');
     }
 
-    // duplicate username check, do this only if user has supplied a new username
-    if(req.body.username !== req.user.username && await username_exists(req.body.username))
-        return res.status(400).send('This username already exists in our database. Please use another one.');
-    // duplicate email check (pseudo-users don't count)
-    let rows2 = await db.read(`SELECT EXISTS(SELECT 1 FROM user WHERE email=? AND password IS NOT NULL) AS email_exists`, [req.body.email]);
-    if(rows2[0].email_exists)
-        return res.status(400).send('This email already exists in our database. Please use another one.');
-    // get pseudo user, if exists
-    let pseudo_user = await db.read(`SELECT * FROM user WHERE email = ? AND password IS NULL`, [req.body.email]);
-    pseudo_user = pseudo_user[0];
-
-    // send_confirmation_code
-    req.body.send_confirmation_code = req.body.send_confirmation_code ?? true;
-
-    // todo email confirmation is required by default unless:
-    // Pseudo user converting and matching uuid is provided
-    let email_confirmation_required = 0;
-
-    // -----------------------------------
-    // Get referral user
-    // -----------------------------------
-    let referred_by_user = undefined;
-    if ( req.body.referral_code ) {
-        referred_by_user = await get_user({ referral_code: req.body.referral_code });
-        if ( ! referred_by_user ) {
-            return res.status(400).send('Referral code not found');
+    const svc_lock = req.services.get('lock');
+    return svc_lock.lock([
+        `save-account:username:${req.body.username}`,
+        `save-account:email:${req.body.email}`
+    ], async () => {
+        await new Promise((rslv) => {
+            setTimeout(rslv, 5000);
+        });
+        // duplicate username check, do this only if user has supplied a new username
+        if(req.body.username !== req.user.username && await username_exists(req.body.username))
+            return res.status(400).send('This username already exists in our database. Please use another one.');
+        // duplicate email check (pseudo-users don't count)
+        let rows2 = await db.read(`SELECT EXISTS(SELECT 1 FROM user WHERE email=? AND password IS NOT NULL) AS email_exists`, [req.body.email]);
+        if(rows2[0].email_exists)
+            return res.status(400).send('This email already exists in our database. Please use another one.');
+        // get pseudo user, if exists
+        let pseudo_user = await db.read(`SELECT * FROM user WHERE email = ? AND password IS NULL`, [req.body.email]);
+        pseudo_user = pseudo_user[0];
+
+        // send_confirmation_code
+        req.body.send_confirmation_code = req.body.send_confirmation_code ?? true;
+
+        // todo email confirmation is required by default unless:
+        // Pseudo user converting and matching uuid is provided
+        let email_confirmation_required = 0;
+
+        // -----------------------------------
+        // Get referral user
+        // -----------------------------------
+        let referred_by_user = undefined;
+        if ( req.body.referral_code ) {
+            referred_by_user = await get_user({ referral_code: req.body.referral_code });
+            if ( ! referred_by_user ) {
+                return res.status(400).send('Referral code not found');
+            }
         }
-    }
-
-    // -----------------------------------
-    // New User
-    // -----------------------------------
-    const user_uuid = req.user.uuid;
-    let email_confirm_code = Math.floor(100000 + Math.random() * 900000);
-    const email_confirm_token = uuidv4();
-
-    if(pseudo_user === undefined){
-        await db.write(
-            `UPDATE user
-             SET
-             username = ?, email = ?, password = ?, email_confirm_code = ?, email_confirm_token = ?${
-                referred_by_user ? ', referred_by = ?' : '' }
-             WHERE
-             id = ?`,
-            [
-                // username
-                req.body.username,
-                // email
-                req.body.email,
-                // password
-                await bcrypt.hash(req.body.password, 8),
-                // email_confirm_code
-                email_confirm_code,
-                //email_confirm_token
-                email_confirm_token,
-                // referred_by
-                ...(referred_by_user ? [referred_by_user.id] : []),
-                // id
-                req.user.id
-            ]
-        );
-        invalidate_cached_user(req.user);
-
-        // Update root directory name
-        await db.write(
-            `UPDATE fsentries SET name = ? WHERE user_id = ? and parent_uid IS NULL`,
-            [
-                // name
-                req.body.username,
-                // id
-                req.user.id,
-            ]
-        );
-        const filesystem = req.services.get('filesystem');
-        await filesystem.update_child_paths(`/${req.user.username}`, `/${req.body.username}`, req.user.id);
-
-        if(req.body.send_confirmation_code)
-            send_email_verification_code(email_confirm_code, req.body.email);
-        else
-            send_email_verification_token(email_confirm_token, req.body.email, user_uuid);
-    }
 
-    // create token for login
-    const svc_auth = req.services.get('auth');
-    const { token } = await svc_auth.create_session_token(req.user, { req });
+        // -----------------------------------
+        // New User
+        // -----------------------------------
+        const user_uuid = req.user.uuid;
+        let email_confirm_code = Math.floor(100000 + Math.random() * 900000);
+        const email_confirm_token = uuidv4();
+
+        if(pseudo_user === undefined){
+            await db.write(
+                `UPDATE user
+                SET
+                username = ?, email = ?, password = ?, email_confirm_code = ?, email_confirm_token = ?${
+                    referred_by_user ? ', referred_by = ?' : '' }
+                WHERE
+                id = ?`,
+                [
+                    // username
+                    req.body.username,
+                    // email
+                    req.body.email,
+                    // password
+                    await bcrypt.hash(req.body.password, 8),
+                    // email_confirm_code
+                    email_confirm_code,
+                    //email_confirm_token
+                    email_confirm_token,
+                    // referred_by
+                    ...(referred_by_user ? [referred_by_user.id] : []),
+                    // id
+                    req.user.id
+                ]
+            );
+            invalidate_cached_user(req.user);
+
+            // Update root directory name
+            await db.write(
+                `UPDATE fsentries SET name = ? WHERE user_id = ? and parent_uid IS NULL`,
+                [
+                    // name
+                    req.body.username,
+                    // id
+                    req.user.id,
+                ]
+            );
+            const filesystem = req.services.get('filesystem');
+            await filesystem.update_child_paths(`/${req.user.username}`, `/${req.body.username}`, req.user.id);
+
+            if(req.body.send_confirmation_code)
+                send_email_verification_code(email_confirm_code, req.body.email);
+            else
+                send_email_verification_token(email_confirm_token, req.body.email, user_uuid);
+        }
 
-    // user id
-    // todo if pseudo user, assign directly no need to do another DB lookup
-    const user_id = req.user.id;
-    const user_res = await db.read('SELECT * FROM `user` WHERE `id` = ? LIMIT 1', [user_id]);
-    const user = user_res[0];
+        // create token for login
+        const svc_auth = req.services.get('auth');
+        const { token } = await svc_auth.create_session_token(req.user, { req });
 
-    // todo send LINK-based verification email
+        // user id
+        // todo if pseudo user, assign directly no need to do another DB lookup
+        const user_id = req.user.id;
+        const user_res = await db.read('SELECT * FROM `user` WHERE `id` = ? LIMIT 1', [user_id]);
+        const user = user_res[0];
 
-    //set cookie
-    res.cookie(config.cookie_name, token);
+        // todo send LINK-based verification email
 
-    {
-        const svc_event = req.services.get('event');
-        svc_event.emit('user.save_account', { user });
-    }
+        //set cookie
+        res.cookie(config.cookie_name, token);
 
-    // return results
-    return res.send({
-        token: token,
-        user:{
-            username: user.username,
-            uuid: user.uuid,
-            email: user.email,
-            is_temp: false,
-            requires_email_confirmation: user.requires_email_confirmation,
-            email_confirmed: user.email_confirmed,
-            email_confirmation_required: email_confirmation_required,
-            taskbar_items: await get_taskbar_items(user),
-            referral_code: user.referral_code,
+        {
+            const svc_event = req.services.get('event');
+            svc_event.emit('user.save_account', { user });
         }
-    })
+
+        // return results
+        return res.send({
+            token: token,
+            user:{
+                username: user.username,
+                uuid: user.uuid,
+                email: user.email,
+                is_temp: false,
+                requires_email_confirmation: user.requires_email_confirmation,
+                email_confirmed: user.email_confirmed,
+                email_confirmation_required: email_confirmation_required,
+                taskbar_items: await get_taskbar_items(user),
+                referral_code: user.referral_code,
+            }
+        })
+    });
 })
 
 module.exports = router

+ 105 - 0
packages/backend/src/services/LockService.js

@@ -0,0 +1,105 @@
+const { RWLock } = require("../util/lockutil");
+const BaseService = require("./BaseService");
+
+/**
+ * LockService implements robust critical sections when the behavior
+ * might return early or throw an error.
+ * 
+ * This serivces uses RWLock but always locks in write mode.
+ */
+class LockService extends BaseService {
+    async _construct () {
+        this.locks = {};
+    }
+    async _init () {
+        const svc_commands = this.services.get('commands');
+        svc_commands.registerCommands('lock', [
+            {
+                id: 'locks',
+                description: 'lists locks',
+                handler: async (args, log) => {
+                    for ( const name in this.locks ) {
+                        let line = name + ': ';
+                        if ( this.locks[name].effective_mode === RWLock.TYPE_READ ) {
+                            line += `READING (${this.locks[name].readers_})`;
+                            log.log(line);
+                        }
+                        else
+                        if ( this.locks[name].effective_mode === RWLock.TYPE_WRITE ) {
+                            line += 'WRITING';
+                            log.log(line);
+                        }
+                        else {
+                            line += 'UNKNOWN';
+                            log.log(line);
+
+                            // log the lock's internal state
+                            const lines = JSON.stringify(
+                                this.locks[name],
+                                null, 2
+                            ).split('\n');
+                            for ( const line of lines ) {
+                                log.log(' -> ' + line);
+                            }
+                        }
+                    }
+                }
+            }
+        ]);
+    }
+
+    async lock (name, opt_options, callback) {
+        if ( typeof opt_options === 'function' ) {
+            callback = opt_options;
+            opt_options = {};
+        }
+
+        // If name is an array, lock all of them
+        if ( Array.isArray(name) ) {
+            const names = name;
+            // TODO: verbose log option by service
+            // console.log('LOCKING NAMES', names)
+            const section = names.reduce((current_callback, name) => {
+                return async () => {
+                    return await this.lock(name, opt_options, current_callback);
+                };
+            }, callback);
+
+            return await section();
+        }
+
+        if ( ! this.locks[name] ) {
+            const rwlock = new RWLock();
+            this.locks[name] = rwlock;
+        }
+
+        const handle = await this.locks[name].wlock();
+        // TODO: verbose log option by service
+        // console.log(`\x1B[36;1mLOCK (${name})\x1B[0m`);
+
+
+        let timeout, timed_out;
+        if ( opt_options.timeout ) {
+            timeout = setTimeout(() => {
+                handle.unlock();
+                // TODO: verbose log option by service
+                // throw new Error(`lock ${name} timed out`);
+            }, opt_options.timeout);
+        }
+
+        try {
+            return await callback();
+        } finally {
+            if ( timeout ) {
+                clearTimeout(timeout);
+            }
+            if ( ! timed_out ) {
+                // TODO: verbose log option by service
+                // console.log(`\x1B[36;1mUNLOCK (${name})\x1B[0m`);
+                handle.unlock();
+            }
+        }
+    }
+}
+
+module.exports = { LockService };

+ 1 - 0
packages/backend/src/services/fs/FSLockService.js

@@ -23,6 +23,7 @@ const BaseService = require("../BaseService");
 const MODE_READ = Symbol('read');
 const MODE_WRITE = Symbol('write');
 
+// TODO: DRY: could use LockService now
 class FSLockService extends BaseService {
     async _construct () {
         this.locks = {};