diff --git a/services/web/app/src/Features/Spelling/LearnedWordsManager.js b/services/web/app/src/Features/Spelling/LearnedWordsManager.js index e8ea2f7d52..8c3a38c37a 100644 --- a/services/web/app/src/Features/Spelling/LearnedWordsManager.js +++ b/services/web/app/src/Features/Spelling/LearnedWordsManager.js @@ -3,21 +3,32 @@ const logger = require('@overleaf/logger') const metrics = require('@overleaf/metrics') const { promisify } = require('util') const OError = require('@overleaf/o-error') +const Settings = require('@overleaf/settings') +const { InvalidError } = require('../Errors/Errors') const LearnedWordsManager = { learnWord(userToken, word, callback) { - return db.spellingPreferences.updateOne( - { - token: userToken, - }, - { - $addToSet: { learnedWords: word }, - }, - { - upsert: true, - }, - callback - ) + LearnedWordsManager.getLearnedWordsSize(userToken, (error, wordsSize) => { + if (error != null) { + return callback(OError.tag(error)) + } + const wordSize = Buffer.from(word).length + if (wordsSize + wordSize > Settings.maxDictionarySize) { + return callback(new InvalidError('Max dictionary size reached')) + } + db.spellingPreferences.updateOne( + { + token: userToken, + }, + { + $addToSet: { learnedWords: word }, + }, + { + upsert: true, + }, + callback + ) + }) }, unlearnWord(userToken, word, callback) { @@ -52,6 +63,20 @@ const LearnedWordsManager = { ) }, + getLearnedWordsSize(userToken, callback) { + db.spellingPreferences.findOne( + { token: userToken }, + function (error, preferences) { + if (error != null) { + return callback(OError.tag(error)) + } + const words = (preferences && preferences.learnedWords) || [] + const wordsSize = Buffer.from(JSON.stringify(words)).length + callback(null, wordsSize) + } + ) + }, + deleteUsersLearnedWords(userToken, callback) { db.spellingPreferences.deleteOne({ token: userToken }, callback) }, diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 9ba43a5216..1b14fbc810 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -423,6 +423,8 @@ module.exports = { 'zh-CN': '简体中文', }, + maxDictionarySize: 1024 * 1024, // 1 MB + // Password Settings // ----------- // These restrict the passwords users can use when registering diff --git a/services/web/test/unit/src/Spelling/LearnedWordsManagerTests.js b/services/web/test/unit/src/Spelling/LearnedWordsManagerTests.js index 4ce814d36e..c6b89ef3c0 100644 --- a/services/web/test/unit/src/Spelling/LearnedWordsManagerTests.js +++ b/services/web/test/unit/src/Spelling/LearnedWordsManagerTests.js @@ -5,6 +5,7 @@ const modulePath = require('path').join( __dirname, '/../../../../app/src/Features/Spelling/LearnedWordsManager' ) +const { InvalidError } = require('../../../../app/src/Features/Errors/Errors') describe('LearnedWordsManager', function () { beforeEach(function () { @@ -13,6 +14,7 @@ describe('LearnedWordsManager', function () { this.db = { spellingPreferences: { updateOne: sinon.stub().yields(), + findOne: sinon.stub().yields(null, ['pear']), }, } this.LearnedWordsManager = SandboxedModule.require(modulePath, { @@ -22,34 +24,56 @@ describe('LearnedWordsManager', function () { timeAsyncMethod: sinon.stub(), inc: sinon.stub(), }, + '@overleaf/settings': { + maxDictionarySize: 20, + }, }, }) }) describe('learnWord', function () { - beforeEach(function () { - this.word = 'instanton' - this.LearnedWordsManager.learnWord(this.token, this.word, this.callback) + describe('under size limit', function () { + beforeEach(function () { + this.word = 'instanton' + this.LearnedWordsManager.learnWord(this.token, this.word, this.callback) + }) + + it('should insert the word in the word list in the database', function () { + expect( + this.db.spellingPreferences.updateOne.calledWith( + { + token: this.token, + }, + { + $addToSet: { learnedWords: this.word }, + }, + { + upsert: true, + } + ) + ).to.equal(true) + }) + + it('should call the callback without error', function () { + sinon.assert.called(this.callback) + expect(this.callback.lastCall.args.length).to.equal(0) + }) }) - it('should insert the word in the word list in the database', function () { - expect( - this.db.spellingPreferences.updateOne.calledWith( - { - token: this.token, - }, - { - $addToSet: { learnedWords: this.word }, - }, - { - upsert: true, - } - ) - ).to.equal(true) - }) + describe('over size limit', function () { + beforeEach(function () { + this.word = 'superlongwordthatwillgobeyondthelimit' + this.LearnedWordsManager.learnWord(this.token, this.word, this.callback) + }) - it('should call the callback', function () { - expect(this.callback.called).to.equal(true) + it('should not insert the word in the word list in the database', function () { + expect(this.db.spellingPreferences.updateOne.notCalled).to.equal(true) + }) + + it('should call the callback with error', function () { + sinon.assert.called(this.callback) + expect(this.callback.lastCall.args[0]).to.be.instanceof(InvalidError) + }) }) }) @@ -100,6 +124,18 @@ describe('LearnedWordsManager', function () { }) }) + describe('getLearnedWordsSize', function () { + it('should return the word list size in the callback', function () { + this.db.spellingPreferences.findOne = (conditions, callback) => { + callback(null, { + learnedWords: ['apples', 'bananas', 'pears', 'bananas'], + }) + } + this.LearnedWordsManager.getLearnedWordsSize(this.token, this.callback) + sinon.assert.calledWith(this.callback, null, 38) + }) + }) + describe('deleteUsersLearnedWords', function () { beforeEach(function () { this.db.spellingPreferences.deleteOne = sinon.stub().callsArgWith(1)