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/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..de8aa49c --- /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', + `You are already at your limit of ${tier.characterSlots} characters`) + } +} + +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/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/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/archive/ArchiveDialog.vue b/app/imports/ui/creature/archive/ArchiveDialog.vue index 0182b339..5d5b041d 100644 --- a/app/imports/ui/creature/archive/ArchiveDialog.vue +++ b/app/imports/ui/creature/archive/ArchiveDialog.vue @@ -35,16 +35,15 @@ slot="actions" text :loading="archiveActionLoading" - :disabled="!numSelected" + :disabled="!numSelected || (mode === 'restore' && characterSlots <= 0)" color="primary" @click="archiveAction" > - {{ mode === 'archive' ? 'Archive' : 'Restore' }} -