diff --git a/app/imports/api/creature/archive/methods/archiveCreatureToFile.js b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js index 7599299c..308eaccb 100644 --- a/app/imports/api/creature/archive/methods/archiveCreatureToFile.js +++ b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js @@ -9,6 +9,7 @@ import CreatureLogs from '/imports/api/creature/log/CreatureLogs.js'; import Experiences from '/imports/api/creature/experience/Experiences.js'; import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature.js'; import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles.js'; +import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileStorageUsed.js'; export function getArchiveObj(creatureId){ // Build the archive document @@ -31,7 +32,7 @@ export function getArchiveObj(creatureId){ return archiveCreature; } -export function archiveCreature(creatureId){ +export function archiveCreature(creatureId, userId){ const archive = getArchiveObj(creatureId); const buffer = Buffer.from(JSON.stringify(archive, null, 2)); ArchiveCreatureFiles.write(buffer, { @@ -43,11 +44,12 @@ export function archiveCreature(creatureId){ creatureId: archive.creature._id, creatureName: archive.creature.name, }, - }, (error) => { + }, (error, file) => { if (error){ throw error; } else { removeCreatureWork(creatureId); + incrementFileStorageUsed(userId, file.size); } }, true); } @@ -68,7 +70,7 @@ const archiveCreatureToFile = new ValidatedMethod({ async run({creatureId}) { assertOwnership(creatureId, this.userId); if (Meteor.isServer){ - archiveCreature(creatureId); + archiveCreature(creatureId, this.userId); } else { removeCreatureWork(creatureId); } diff --git a/app/imports/api/creature/archive/methods/index.js b/app/imports/api/creature/archive/methods/index.js index d3784bde..f2c61b6d 100644 --- a/app/imports/api/creature/archive/methods/index.js +++ b/app/imports/api/creature/archive/methods/index.js @@ -2,3 +2,4 @@ import '/imports/api/creature/archive/methods/archiveCreatureToFile.js'; import '/imports/api/creature/archive/methods/restoreCreatures.js'; import '/imports/api/creature/archive/methods/restoreCreatureFromFile.js'; +import '/imports/api/creature/archive/methods/removeArchiveCreature.js'; diff --git a/app/imports/api/creature/archive/methods/removeArchiveCreature.js b/app/imports/api/creature/archive/methods/removeArchiveCreature.js new file mode 100644 index 00000000..22bf858d --- /dev/null +++ b/app/imports/api/creature/archive/methods/removeArchiveCreature.js @@ -0,0 +1,47 @@ +import SCHEMA_VERSION from '/imports/constants/SCHEMA_VERSION.js'; +import SimpleSchema from 'simpl-schema'; +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import Creatures from '/imports/api/creature/creatures/Creatures.js'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import CreatureLogs from '/imports/api/creature/log/CreatureLogs.js'; +import Experiences from '/imports/api/creature/experience/Experiences.js'; +import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature.js'; +import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles.js'; +import assertHasCharactersSlots from '/imports/api/creature/creatures/methods/assertHasCharacterSlots.js'; +import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileStorageUsed.js'; + +const removeArchiveCreature = new ValidatedMethod({ + name: 'ArchiveCreatureFiles.methods.removeArchiveCreature', + validate: new SimpleSchema({ + 'fileId': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + async run({ fileId }) { + // fetch the file + const file = ArchiveCreatureFiles.findOne({ _id: fileId }).get(); + if (!file) { + throw new Meteor.Error('File not found', + 'The requested creature archive does not exist'); + } + // Assert ownership + const userId = file?.userId; + if (!userId || userId !== this.userId) { + throw new Meteor.Error('Permission denied', + 'You can only restore creatures you own'); + } + //Remove the archive once the restore succeeded + ArchiveCreatureFiles.remove({ _id: fileId }); + // Update the user's file storage limits + incrementFileStorageUsed(userId, -file.size); + }, +}); + +export default removeArchiveCreature; diff --git a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js index 986bb9ca..9130390a 100644 --- a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js +++ b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js @@ -8,6 +8,9 @@ import CreatureLogs from '/imports/api/creature/log/CreatureLogs.js'; import Experiences from '/imports/api/creature/experience/Experiences.js'; import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature.js'; import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles.js'; +import assertHasCharactersSlots from '/imports/api/creature/creatures/methods/assertHasCharacterSlots.js'; +import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileStorageUsed.js'; + let migrateArchive; if (Meteor.isServer){ migrateArchive = require('/imports/migrations/server/migrateArchive.js').default; @@ -69,13 +72,18 @@ const restoreCreaturefromFile = new ValidatedMethod({ throw new Meteor.Error('Permission denied', 'You can only restore creatures you own'); } + + assertHasCharactersSlots(this.userId); + if (Meteor.isServer){ // Read the file data const archive = await ArchiveCreatureFiles.readJSONFile(file); restoreCreature(archive); } //Remove the archive once the restore succeeded - ArchiveCreatureFiles.remove({_id: fileId}); + ArchiveCreatureFiles.remove({ _id: fileId }); + // Update the user's file storage limits + incrementFileStorageUsed(userId, -file.size); }, }); diff --git a/app/imports/api/creature/creatures/methods/assertHasCharacterSlots.js b/app/imports/api/creature/creatures/methods/assertHasCharacterSlots.js new file mode 100644 index 00000000..5b368e5f --- /dev/null +++ b/app/imports/api/creature/creatures/methods/assertHasCharacterSlots.js @@ -0,0 +1,22 @@ +import { getUserTier } from '/imports/api/users/patreon/tiers.js'; +import Creatures from '/imports/api/creature/creatures/Creatures.js'; + +export default function assertHasCharactersSlots(userId) { + if (characterSlotsRemaining(userId) <= 0) { + throw new Meteor.Error('characterSlotLimit', + 'No character slots left') + } +} + +export function characterSlotsRemaining(userId) { + let tier = getUserTier(userId); + const currentCharacterCount = Creatures.find({ + owner: userId, + }, { + fields: { _id: 1 }, + }).count(); + if (tier.characterSlots === -1) { + return Number.POSITIVE_INFINITY; + } + return tier.characterSlots - currentCharacterCount; +} diff --git a/app/imports/api/creature/creatures/methods/insertCreature.js b/app/imports/api/creature/creatures/methods/insertCreature.js index 79086eb3..7dde2bbb 100644 --- a/app/imports/api/creature/creatures/methods/insertCreature.js +++ b/app/imports/api/creature/creatures/methods/insertCreature.js @@ -2,9 +2,9 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import Creatures from '/imports/api/creature/creatures/Creatures.js'; import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import { getUserTier } from '/imports/api/users/patreon/tiers.js'; import defaultCharacterProperties from '/imports/api/creature/creatures/defaultCharacterProperties.js'; import insertPropertyFromLibraryNode from '/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js'; +import assertHasCharactersSlots from '/imports/api/creature/creatures/methods/assertHasCharacterSlots.js'; const insertCreature = new ValidatedMethod({ @@ -23,21 +23,8 @@ const insertCreature = new ValidatedMethod({ throw new Meteor.Error('Creatures.methods.insert.denied', 'You need to be logged in to insert a creature'); } - let tier = getUserTier(this.userId); - let currentCharacterCount = Creatures.find({ - owner: this.userId, - }, { - fields: {_id: 1}, - }).count(); - - if ( - tier.characterSlots !== -1 && - currentCharacterCount >= tier.characterSlots - ){ - throw new Meteor.Error('Creatures.methods.insert.denied', - `You are already at your limit of ${tier.characterSlots} characters`) - } + assertHasCharactersSlots(this.userId); // Create the creature document let creatureId = Creatures.insert({ diff --git a/app/imports/api/files/UserImages.js b/app/imports/api/files/UserImages.js new file mode 100644 index 00000000..ead7271b --- /dev/null +++ b/app/imports/api/files/UserImages.js @@ -0,0 +1,18 @@ +import { createS3FilesCollection } from '/imports/api/files/s3FileStorage.js'; + +const UserImages = createS3FilesCollection({ + collectionName: 'userImages', + storagePath: Meteor.isDevelopment ? '/DiceCloud/userImages/' : 'assets/app/userImages', + onBeforeUpload(file) { + // Allow upload files under 10MB + if (file.size > 10485760) { + return 'Please upload with size equal or less than 10MB'; + } + // Allow common image extensions + if (/gif|png|jpe?g|webp/i.test(file.extension || '')) { + return 'Please upload an image file only'; + } + } +}); + +export default UserImages; diff --git a/app/imports/api/files/s3FileStorage.js b/app/imports/api/files/s3FileStorage.js index 30e43cc3..e26e6ac5 100644 --- a/app/imports/api/files/s3FileStorage.js +++ b/app/imports/api/files/s3FileStorage.js @@ -213,9 +213,6 @@ if (Meteor.isServer && Meteor.settings.useS3) { return collection; } } else { - if (Meteor.isServer){ - // console.log('No S3 details specified, files will be stored in the local filesystem'); - } createS3FilesCollection = function({ collectionName, storagePath, diff --git a/app/imports/api/users/Users.js b/app/imports/api/users/Users.js index be3fd3a8..31a4d818 100644 --- a/app/imports/api/users/Users.js +++ b/app/imports/api/users/Users.js @@ -4,6 +4,7 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import '/imports/api/users/methods/deleteMyAccount.js'; import '/imports/api/users/methods/addEmail.js'; import '/imports/api/users/methods/removeEmail.js'; +import '/imports/api/users/methods/updateFileStorageUsed.js'; import { some } from 'lodash'; const defaultLibraries = process.env.DEFAULT_LIBRARIES && process.env.DEFAULT_LIBRARIES.split(',') || []; @@ -84,6 +85,10 @@ const userSchema = new SimpleSchema({ type: String, regEx: SimpleSchema.RegEx.Id, }, + fileStorageUsed: { + type: Number, + optional: true, + }, profile: { type: Object, blackbox: true, diff --git a/app/imports/api/users/methods/updateFileStorageUsed.js b/app/imports/api/users/methods/updateFileStorageUsed.js new file mode 100644 index 00000000..4fad2feb --- /dev/null +++ b/app/imports/api/users/methods/updateFileStorageUsed.js @@ -0,0 +1,71 @@ +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles.js'; +import UserImages from '/imports/api/files/UserImages.js'; +const fileCollections = [ArchiveCreatureFiles, UserImages]; + +const updateFileStorageUsed = new ValidatedMethod({ + name: 'users.recalculateFileStorageUsed', + validate: null, + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run() { + const userId = Meteor.userId(); + if (!userId) throw new Meteor.Error('No user', + 'You must be logged in to recalculate your file use'); + const user = Meteor.users.findOne(userId); + if (!user) { + throw new Meteor.Error('noUser', 'User not found'); + } + updateFileStorageUsedWork(userId); + } +}); + +export default updateFileStorageUsed; + +export function updateFileStorageUsedWork(userId) { + if (!userId) { + throw new Meteor.Error('idRequired', + 'No user ID was provided to update file storage used') + } + + let sum = 0; + fileCollections.forEach(collection => { + collection.find({ userId }, { fields: { size: 1 } }).forEach(file => { + sum += file.size; + }); + }); + + Meteor.users.update(userId, { + $set: { + fileStorageUsed: sum, + } + }); +} + +export function incrementFileStorageUsed(userId, amount) { + if (!userId) { + throw new Meteor.Error('idRequired', + 'No user ID was provided to update file storage used') + } + + const user = Meteor.users.findOne(userId); + if (!user) { + throw new Meteor.Error('noUser', 'User not found'); + } + + if (user.fileStorageUsed === undefined) { + // The user doesn't have a current value for storage used, calculate it + // from scratch + updateFileStorageUsedWork(userId); + } else { + Meteor.users.update(userId, { + $inc: { + fileStorageUsed: amount, + } + }); + } +} diff --git a/app/imports/api/users/patreon/tiers.js b/app/imports/api/users/patreon/tiers.js index 32f0465d..2d789993 100644 --- a/app/imports/api/users/patreon/tiers.js +++ b/app/imports/api/users/patreon/tiers.js @@ -9,18 +9,21 @@ const TIERS = Object.freeze([ minimumEntitledCents: 0, invites: 0, characterSlots: 5, + fileStorage: 50, paidBenefits: false, }, { name: 'Dreamer', minimumEntitledCents: 100, invites: 0, characterSlots: 5, + fileStorage: 50, paidBenefits: false, }, { name: 'Wanderer', minimumEntitledCents: 300, invites: 0, characterSlots: 5, + fileStorage: 50, paidBenefits: false, }, { //cost per user $5 @@ -28,6 +31,7 @@ const TIERS = Object.freeze([ minimumEntitledCents: 500, invites: 0, characterSlots: 20, + fileStorage: 200, paidBenefits: true, }, { //cost per user $3.33 @@ -35,6 +39,7 @@ const TIERS = Object.freeze([ minimumEntitledCents: 1000, invites: 2, characterSlots: 50, + fileStorage: 500, paidBenefits: true, }, { //cost per user $3.333 @@ -42,6 +47,7 @@ const TIERS = Object.freeze([ minimumEntitledCents: 2000, invites: 5, characterSlots: 120, + fileStorage: 1000, paidBenefits: true, }, { //cost per user $3.125 @@ -49,6 +55,7 @@ const TIERS = Object.freeze([ minimumEntitledCents: 5000, invites: 15, characterSlots: -1, // Unlimited characters + fileStorage: 2000, paidBenefits: true, }, ]); @@ -59,6 +66,7 @@ const GUEST_TIER = Object.freeze({ guest: true, invites: 0, characterSlots: 20, + fileStorage: 200, paidBenefits: true, }); @@ -68,6 +76,7 @@ const PATREON_DISABLED_TIER = Object.freeze({ name: 'Outlander', invites: 0, characterSlots: -1, // Can have infinitely many characters + fileStorage: 1000000, // 1TB file storage paidBenefits: true, }); diff --git a/app/imports/parser/grammar.js b/app/imports/parser/grammar.js index 314aedd3..d8a449f8 100644 --- a/app/imports/parser/grammar.js +++ b/app/imports/parser/grammar.js @@ -1,4 +1,4 @@ -// Generated automatically by nearley, version 2.20.1 +// Generated automatically by nearley, version 2.16.0 // http://github.com/Hardmath123/nearley function id(x) { return x[0]; } @@ -85,26 +85,24 @@ let ParserRules = [ d => node.call.create({functionName: d[0].name, args: d[2]}) }, {"name": "callExpression", "symbols": ["indexExpression"], "postprocess": id}, - {"name": "arguments$ebnf$1$subexpression$1", "symbols": ["expression"], "postprocess": d => d[0]}, - {"name": "arguments$ebnf$1", "symbols": ["arguments$ebnf$1$subexpression$1"], "postprocess": id}, - {"name": "arguments$ebnf$1", "symbols": [], "postprocess": function(d) {return null;}}, - {"name": "arguments$ebnf$2", "symbols": []}, - {"name": "arguments$ebnf$2$subexpression$1", "symbols": ["_", (lexer.has("separator") ? {type: "separator"} : separator), "_", "expression"], "postprocess": d => d[3]}, - {"name": "arguments$ebnf$2", "symbols": ["arguments$ebnf$2", "arguments$ebnf$2$subexpression$1"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, - {"name": "arguments", "symbols": [{"literal":"("}, "_", "arguments$ebnf$1", "arguments$ebnf$2", "_", {"literal":")"}], "postprocess": + {"name": "arguments$subexpression$1", "symbols": ["expression"], "postprocess": d => d[0]}, + {"name": "arguments$ebnf$1", "symbols": []}, + {"name": "arguments$ebnf$1$subexpression$1", "symbols": ["_", (lexer.has("separator") ? {type: "separator"} : separator), "_", "expression"], "postprocess": d => d[3]}, + {"name": "arguments$ebnf$1", "symbols": ["arguments$ebnf$1", "arguments$ebnf$1$subexpression$1"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "arguments", "symbols": [{"literal":"("}, "_", "arguments$subexpression$1", "arguments$ebnf$1", "_", {"literal":")"}], "postprocess": d => [d[2], ...d[3]] }, + {"name": "arguments", "symbols": [{"literal":"("}, "_", {"literal":")"}], "postprocess": d => []}, {"name": "indexExpression", "symbols": ["arrayExpression", {"literal":"["}, "_", "expression", "_", {"literal":"]"}], "postprocess": d => node.index.create({array: d[0], index: d[3]})}, {"name": "indexExpression", "symbols": ["arrayExpression"], "postprocess": id}, - {"name": "arrayExpression$ebnf$1$subexpression$1", "symbols": ["expression"], "postprocess": d => d[0]}, - {"name": "arrayExpression$ebnf$1", "symbols": ["arrayExpression$ebnf$1$subexpression$1"], "postprocess": id}, - {"name": "arrayExpression$ebnf$1", "symbols": [], "postprocess": function(d) {return null;}}, - {"name": "arrayExpression$ebnf$2", "symbols": []}, - {"name": "arrayExpression$ebnf$2$subexpression$1", "symbols": ["_", (lexer.has("separator") ? {type: "separator"} : separator), "_", "expression"], "postprocess": d => d[3]}, - {"name": "arrayExpression$ebnf$2", "symbols": ["arrayExpression$ebnf$2", "arrayExpression$ebnf$2$subexpression$1"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, - {"name": "arrayExpression", "symbols": [{"literal":"["}, "_", "arrayExpression$ebnf$1", "arrayExpression$ebnf$2", "_", {"literal":"]"}], "postprocess": - d => node.array.create({values: d[2] ? [d[2], ...d[3]] : []}) + {"name": "arrayExpression$subexpression$1", "symbols": ["expression"], "postprocess": d => d[0]}, + {"name": "arrayExpression$ebnf$1", "symbols": []}, + {"name": "arrayExpression$ebnf$1$subexpression$1", "symbols": ["_", (lexer.has("separator") ? {type: "separator"} : separator), "_", "expression"], "postprocess": d => d[3]}, + {"name": "arrayExpression$ebnf$1", "symbols": ["arrayExpression$ebnf$1", "arrayExpression$ebnf$1$subexpression$1"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "arrayExpression", "symbols": [{"literal":"["}, "_", "arrayExpression$subexpression$1", "arrayExpression$ebnf$1", "_", {"literal":"]"}], "postprocess": + d => node.array.create({ values: [d[2], ...d[3]] }) }, + {"name": "arrayExpression", "symbols": [{"literal":"["}, "_", {"literal":"]"}], "postprocess": d => node.array.create({ values: [] })}, {"name": "arrayExpression", "symbols": ["parenthesizedExpression"], "postprocess": id}, {"name": "parenthesizedExpression", "symbols": [{"literal":"("}, "_", "expression", "_", {"literal":")"}], "postprocess": d => node.parenthesis.create({content: d[2]})}, {"name": "parenthesizedExpression", "symbols": ["accessorExpression"], "postprocess": id}, diff --git a/app/imports/parser/grammar.ne b/app/imports/parser/grammar.ne index 85743701..e2f7d1bf 100644 --- a/app/imports/parser/grammar.ne +++ b/app/imports/parser/grammar.ne @@ -119,18 +119,20 @@ callExpression -> | indexExpression {% id %} arguments -> -"(" _ (expression {% d => d[0] %}):? ( _ %separator _ expression {% d => d[3] %} ):* _ ")" {% +"(" _ (expression {% d => d[0] %}) ( _ %separator _ expression {% d => d[3] %} ):* _ ")" {% d => [d[2], ...d[3]] %} +| "(" _ ")" {% d => [] %} indexExpression -> arrayExpression "[" _ expression _ "]" {% d => node.index.create({array: d[0], index: d[3]}) %} | arrayExpression {% id %} arrayExpression -> - "[" _ (expression {% d => d[0] %}):? ( _ %separator _ expression {% d => d[3] %} ):* _ "]" {% - d => node.array.create({values: d[2] ? [d[2], ...d[3]] : []}) + "[" _ (expression {% d => d[0] %}) ( _ %separator _ expression {% d => d[3] %} ):* _ "]" {% + d => node.array.create({ values: [d[2], ...d[3]] }) %} +| "[" _ "]" {% d => node.array.create({ values: [] }) %} | parenthesizedExpression {% id %} parenthesizedExpression -> diff --git a/app/imports/parser/parseTree/call.js b/app/imports/parser/parseTree/call.js index f5b1c91e..658c5f49 100644 --- a/app/imports/parser/parseTree/call.js +++ b/app/imports/parser/parseTree/call.js @@ -33,6 +33,7 @@ const call = { // Check that the arguments match what is expected let checkFailed = call.checkArugments({ + node, fn, resolvedArgs, argumentsExpected: func.arguments, diff --git a/app/imports/server/publications/users.js b/app/imports/server/publications/users.js index c3e0c101..289f60dc 100644 --- a/app/imports/server/publications/users.js +++ b/app/imports/server/publications/users.js @@ -10,6 +10,7 @@ Meteor.publish('user', function(){ apiKey: 1, darkMode: 1, subscribedLibraries: 1, + fileStorageUsed: 1, profile: 1, preferences: 1, 'services.patreon.id': 1, diff --git a/app/imports/ui/creature/CreatureFormDialog.vue b/app/imports/ui/creature/CreatureFormDialog.vue index 7f680da9..9a80adce 100644 --- a/app/imports/ui/creature/CreatureFormDialog.vue +++ b/app/imports/ui/creature/CreatureFormDialog.vue @@ -1,5 +1,5 @@