diff --git a/app/imports/api/creature/archive/methods/archiveCreatureToFile.js b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js index 57d0002b6..bce9ecd3c 100644 --- a/app/imports/api/creature/archive/methods/archiveCreatureToFile.js +++ b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js @@ -9,6 +9,7 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur import CreatureLogs from '/imports/api/creature/log/CreatureLogs'; import Experiences from '/imports/api/creature/experience/Experiences'; import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature'; +import { toSoftArchive } from '/imports/api/creature/archive/methods/softArchiveCreature'; import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles'; import { getFilter } from '/imports/api/parenting/parentingFunctions'; @@ -34,7 +35,7 @@ export function getArchiveObj(creatureId) { return archiveCreature; } -export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creatureId, callback) { +export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creatureId, autoArchive, callback) { const archive = getArchiveObj(creatureId); const buffer = Buffer.from(JSON.stringify(archive, null, 2)); ArchiveCreatureFiles.write(buffer, { @@ -45,6 +46,7 @@ export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creat schemaVersion: SCHEMA_VERSION, creatureId: archive.creature._id, creatureName: archive.creature.name, + auto: !!autoArchive, }, }, (error, fileRef) => { if (error) { @@ -52,7 +54,11 @@ export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creat callback(error); } else if (!Meteor.settings.useS3) { // If we aren't using s3, remove the creature and call the callback - removeCreatureWork(creatureId); + if (autoArchive) { + toSoftArchive(creatureId, fileRef._id); + } else { + removeCreatureWork(creatureId); + } callback(); } else { // Wait for s3Result event that occurs when the s3 attempt to write ends. @@ -64,7 +70,11 @@ export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creat ArchiveCreatureFiles.off('s3Result', resultHandler); // Remove the creature if there was no error if (!s3Error) { - removeCreatureWork(creatureId); + if (autoArchive) { + toSoftArchive(creatureId, fileRef._id); + } else { + removeCreatureWork(creatureId); + } } // Alert the callback that we're done callback(s3Error); @@ -74,6 +84,26 @@ export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creat }, true); }); +export function softArchiveToArchive(creatureId) { + let creature = Creatures.findOne({ _id: creatureId }); + Creatures.remove({ _id: creatureId }); + ArchiveCreatureFiles.update( + { + _id: creature.archiveId, + 'meta.creatureId': creature._id, + 'meta.auto': true, + }, + { + $set: { 'meta.auto': false }, + }, error => { + this.archiveActionLoading = false; + if (!error) return; + console.error(error); + snackbar({text: error.reason}); + } + ); +} + const archiveCreatureToFile = new ValidatedMethod({ name: 'Creatures.methods.archiveCreatureToFile', validate: new SimpleSchema({ @@ -90,7 +120,12 @@ const archiveCreatureToFile = new ValidatedMethod({ async run({ creatureId }) { assertOwnership(creatureId, this.userId); if (Meteor.isServer) { - archiveCreature(creatureId); + let creature = Creatures.findOne({ _id: creatureId }, { fields: { archiveId: 1 }}); + if (creature.archiveId) { + softArchiveToArchive(creatureId); + } else { + archiveCreature(creatureId, false); + } } else { removeCreatureWork(creatureId); } diff --git a/app/imports/api/creature/archive/methods/removeArchiveCreature.js b/app/imports/api/creature/archive/methods/removeArchiveCreature.js index 7018b0b13..bc57ccea3 100644 --- a/app/imports/api/creature/archive/methods/removeArchiveCreature.js +++ b/app/imports/api/creature/archive/methods/removeArchiveCreature.js @@ -2,6 +2,7 @@ import SimpleSchema from 'simpl-schema'; import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles'; +import Creatures from '/imports/api/creature/creatures/Creatures'; import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileStorageUsed'; const removeArchiveCreature = new ValidatedMethod({ @@ -30,6 +31,15 @@ const removeArchiveCreature = new ValidatedMethod({ throw new Meteor.Error('Permission denied', 'You can only restore creatures you own'); } + if (file.meta.auto) { + Creatures.remove( + { + _id: file.meta.creatureId, + owner: this.userId, + archiveId: file._id, + } + ); + } //Remove the archive once the restore succeeded ArchiveCreatureFiles.remove({ _id: fileId }); // Update the user's file storage limits diff --git a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js index 4a8da16aa..12c4725ea 100644 --- a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js +++ b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js @@ -101,4 +101,84 @@ const restoreCreaturefromFile = new ValidatedMethod({ }, }); +export const restoreSoftArchive = Meteor.wrapAsync(async function restoreSoftArchiveFn(creatureId, callback) { + if (!Meteor.isServer) return; + + // Verify that the creature and archive match + const existingCreature = Creatures.findOne( + { _id: creatureId }, + { + fields: { + _id: 1, + owner: 1, + archiveId: 1, + }, + } + ); + if (!existingCreature) throw new Meteor.Error('Doesn\'t exist', + 'The creature you are trying to restore doesn\'t exist'); + + if (!existingCreature.archiveId) throw new Meteor.Error('No Linked Archive', + 'The creature doesn\'t have a linked archive.'); + + const archiveFile = ArchiveCreatureFiles.findOne({ + _id: existingCreature.archiveId, + userId: existingCreature.owner, + 'meta.auto': true, + 'meta.creatureId': creatureId, + }); + + const archive = await ArchiveCreatureFiles.readJSONFile(archiveFile); + + if (SCHEMA_VERSION < archive.meta.schemaVersion) { + throw new Meteor.Error('Incompatible', + 'The archive file is from a newer version. Update required to read.') + } + + // Migrate and verify the archive meets the current schema + migrateArchive(archive); + + // Asset that the archive is safe + verifyArchiveSafety(archive); + + try { + // Add all the properties + if (archive.properties && archive.properties.length) { + CreatureProperties.batchInsert(archive.properties); + } + if (archive.experiences && archive.experiences.length) { + Experiences.batchInsert(archive.experiences); + } + if (archive.logs && archive.logs.length) { + CreatureLogs.batchInsert(archive.logs); + } + + Creatures.update( + { _id: creatureId }, + { $unset: { + archiveId: true, + }} + ); + } catch (e) { + // If the above fails, delete the inserted creature + CreatureVariables.remove({ _creatureId: creatureId }); + CreatureProperties.remove(getFilter.descendantsOfRoot(creatureId)); + CreatureLogs.remove({ creatureId }); + Experiences.remove({ creatureId }); + Creatures.update( + { _id: creatureId }, + { $set: { + archiveId: archiveFile._id, + }} + ); + console.log(e); + throw e; + } + //Remove the archive once the restore succeeded + ArchiveCreatureFiles.remove({ _id: archiveFile._id }); + // Update the user's file storage limits + incrementFileStorageUsed(archiveFile.userId, -archiveFile.size); + callback(); +}); + export default restoreCreaturefromFile; diff --git a/app/imports/api/creature/archive/methods/softArchiveCreature.js b/app/imports/api/creature/archive/methods/softArchiveCreature.js new file mode 100644 index 000000000..e7eb31a9f --- /dev/null +++ b/app/imports/api/creature/archive/methods/softArchiveCreature.js @@ -0,0 +1,27 @@ +import Creatures from '/imports/api/creature/creatures/Creatures'; +import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties'; +import CreatureLogs from '/imports/api/creature/log/CreatureLogs'; +import Experiences from '/imports/api/creature/experience/Experiences'; +import { getFilter } from '/imports/api/parenting/parentingFunctions'; + +function removeRelatedDocuments(creatureId) { + CreatureVariables.remove({ _creatureId: creatureId }); + CreatureProperties.remove(getFilter.descendantsOfRoot(creatureId)); + CreatureLogs.remove({ creatureId }); + Experiences.remove({ creatureId }); +} + +export function toSoftArchive(creatureId, archiveId) { + if (!Meteor.isServer) return; + + Creatures.update( + { _id: creatureId }, + { + $set: { + archiveId: archiveId, + }, + } + ); + removeRelatedDocuments(creatureId); +} diff --git a/app/imports/api/creature/creatures/Creatures.ts b/app/imports/api/creature/creatures/Creatures.ts index e6b170e61..1d1e0bf24 100644 --- a/app/imports/api/creature/creatures/Creatures.ts +++ b/app/imports/api/creature/creatures/Creatures.ts @@ -180,6 +180,12 @@ const CreatureSchema = TypedSimpleSchema.from({ type: CreatureSettingsSchema, defaultValue: {}, }, + + // Was auto archived + 'archiveId': { + type: String, + optional: true, + }, }) .extend(ColorSchema) .extend(SharingSchema); diff --git a/app/imports/api/users/methods/updateFileStorageUsed.js b/app/imports/api/users/methods/updateFileStorageUsed.js index 656915b22..d055727c1 100644 --- a/app/imports/api/users/methods/updateFileStorageUsed.js +++ b/app/imports/api/users/methods/updateFileStorageUsed.js @@ -2,7 +2,6 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles'; import UserImages from '/imports/api/files/userImages/UserImages'; -const fileCollections = [ArchiveCreatureFiles, UserImages]; const updateFileStorageUsed = new ValidatedMethod({ name: 'users.recalculateFileStorageUsed', @@ -33,10 +32,11 @@ export function updateFileStorageUsedWork(userId) { } let sum = 0; - fileCollections.forEach(collection => { - collection.find({ userId }, { fields: { size: 1 } }).forEach(file => { - sum += file.size; - }); + ArchiveCreatureFiles.find({ userId }, { fields: { size: 1 } }).forEach(file => { + sum += file.size; + }); + UserImages.find({ userId }, { fields: { size: 1 } }).forEach(file => { + sum += file.size; }); Meteor.users.update(userId, { diff --git a/app/imports/client/ui/creature/archive/ArchiveDialog.vue b/app/imports/client/ui/creature/archive/ArchiveDialog.vue index 8113ac427..67610d963 100644 --- a/app/imports/client/ui/creature/archive/ArchiveDialog.vue +++ b/app/imports/client/ui/creature/archive/ArchiveDialog.vue @@ -27,8 +27,8 @@ selection :creatures="mode === 'archive' ? CreaturesWithNoParty : archiveCreaturesWithNoParty" :folders="mode === 'archive' ? folders : archivefolders" - :selected-creature="selectedCreature" - @creature-selected="id => selectedCreature = id" + :selected-creature-id="selectedCreatureId" + @creature-selected="id => selectedCreatureId = id" /> { this.archiveActionLoading = false; if (!error) return; console.error(error); snackbar({text: error.reason}); }); - } else if (this.mode === 'restore'){ + } else if (this.mode === 'restore') { restoreCreatureFromFile.call({ - fileId: this.selectedCreature, + fileId: this.selectedCreatureId, }, error => { this.archiveActionLoading = false; if (!error) return; @@ -139,7 +140,7 @@ export default { snackbar({text: error.reason}); }); } - this.selectedCreature = null; + this.selectedCreatureId = null; } }, meteor: { @@ -194,6 +195,10 @@ export default { folder.creatures = ArchiveCreatureFiles.find( { 'meta.creatureId': {$in: folder.creatures || []}, + $or: [ + { 'meta.auto': false }, + { 'meta.auto': { $exists: false } }, + ], userId, }, { sort: {'meta.creatureName': 1}, @@ -211,6 +216,10 @@ export default { return ArchiveCreatureFiles.find( { 'meta.creatureId': {$nin: folderChars}, + $or: [ + { 'meta.auto': false }, + { 'meta.auto': { $exists: false } }, + ], userId, }, { sort: {'meta.creatureName': 1}, diff --git a/app/imports/client/ui/creature/creatureList/CreatureFolderList.vue b/app/imports/client/ui/creature/creatureList/CreatureFolderList.vue index 5c4806d59..ecf5be234 100644 --- a/app/imports/client/ui/creature/creatureList/CreatureFolderList.vue +++ b/app/imports/client/ui/creature/creatureList/CreatureFolderList.vue @@ -8,7 +8,7 @@ @@ -34,7 +34,7 @@ :creatures="folder.creatures" :folder-id="folder._id" :selection="selection" - :selected-creature="selectedCreature" + :selected-creature-id="selectedCreatureId" :dense="dense" @creature-selected="id => $emit('creature-selected', id)" /> @@ -62,7 +62,7 @@ export default { default: () => [], }, selection: Boolean, - selectedCreature: { + selectedCreatureId: { type: String, default: undefined, }, diff --git a/app/imports/client/ui/creature/creatureList/CreatureList.vue b/app/imports/client/ui/creature/creatureList/CreatureList.vue index f8869b013..d300c74fc 100644 --- a/app/imports/client/ui/creature/creatureList/CreatureList.vue +++ b/app/imports/client/ui/creature/creatureList/CreatureList.vue @@ -15,7 +15,7 @@ class="creature" :model="creature" :selection="selection" - :is-selected="selectedCreature === creature._id || selectedCreatures.has(creature._id)" + :is-selected="selectedCreatureId === creature._id || selectedCreatureIds.has(creature._id)" v-bind="selection ? {} : {to: creature.url}" :dense="dense" :data-id="dense ? undefined : creature._id" @@ -45,11 +45,11 @@ default: null, }, selection: Boolean, - selectedCreature: { + selectedCreatureId: { type: String, default: undefined, }, - selectedCreatures: { + selectedCreatureIds: { type: Set, default: () => new Set(), }, diff --git a/app/imports/client/ui/files/ArchiveFileCard.vue b/app/imports/client/ui/files/ArchiveFileCard.vue index 9eb926ac8..95dff9b19 100644 --- a/app/imports/client/ui/files/ArchiveFileCard.vue +++ b/app/imports/client/ui/files/ArchiveFileCard.vue @@ -4,17 +4,24 @@ {{ model.meta.creatureName }} - {{ model.size }} + {{ model.size }} {{ model.meta.auto ? "(Automatically Archived)" : ""}} Restore + + Open + { f.size = prettyBytes(f.size); f.link = ArchiveCreatureFiles.link(f); + f.url = `/character/${f.meta.creatureId}/${getCreatureUrlName({ name: f.meta.creatureName })}` return f; }); }, diff --git a/app/imports/server/cron/archiveOldCreatures.js b/app/imports/server/cron/archiveOldCreatures.js new file mode 100644 index 000000000..2755064a8 --- /dev/null +++ b/app/imports/server/cron/archiveOldCreatures.js @@ -0,0 +1,40 @@ +import Creatures from '/imports/api/creature/creatures/Creatures'; +import { archiveCreature } from '/imports/api/creature/archive/methods/archiveCreatureToFile'; +import { assertAdmin } from '/imports/api/sharing/sharingPermissions'; +import { SyncedCron } from 'meteor/littledata:synced-cron'; +const archiveAfterDays = Meteor.settings?.archiveAfterDays; + +Meteor.startup(() => { + /** + * Archive all creatures older than the configured amount of days. + */ + const archiveOldCreatures = function () { + if (typeof archiveAfterDays != 'number') { + return; + } + const now = new Date(); + const expire = new Date(now.getTime() - (archiveAfterDays * 24 * 60 * 60 * 1000)); + Creatures.find({ lastComputedAt: { $lt: expire }, archiveId: { $exists: false } }, { _id: 1 }).forEach( creature => { + archiveCreature(creature._id, true); + }); + } + + SyncedCron.add({ + name: 'archiveOldCreatures', + schedule: function (parser) { + return parser.text('every 10 minutes'); + }, + job: archiveOldCreatures, + }); + + SyncedCron.start(); + + // Add a method to manually trigger removal + Meteor.methods({ + archiveOldCreatures() { + assertAdmin(this.userId); + this.unblock(); + archiveOldCreatures(); + }, + }); +}); diff --git a/app/imports/server/publications/characterList.js b/app/imports/server/publications/characterList.js index 6f2f1be92..50dedbc77 100644 --- a/app/imports/server/publications/characterList.js +++ b/app/imports/server/publications/characterList.js @@ -34,6 +34,11 @@ Meteor.publish('characterList', function () { avatarPicture: 1, public: 1, type: 1, + archiveId: { $cond: { + if: "$archiveId", + then: true, + else: null, + }} } } ), diff --git a/app/imports/server/publications/singleCharacter.js b/app/imports/server/publications/singleCharacter.js index c6c0581be..91d56dacf 100644 --- a/app/imports/server/publications/singleCharacter.js +++ b/app/imports/server/publications/singleCharacter.js @@ -9,6 +9,7 @@ import VERSION from '/imports/constants/VERSION'; import { loadCreature } from '/imports/api/engine/loadCreatures'; import { rebuildCreatureNestedSets } from '/imports/api/parenting/parentingFunctions'; import EngineActions from '/imports/api/engine/action/EngineActions'; +import { restoreSoftArchive } from '/imports/api/creature/archive/methods/restoreCreatureFromFile'; let schema = new SimpleSchema({ creatureId: { @@ -36,10 +37,14 @@ Meteor.publish('singleCharacter', function (creatureId) { public: 1, computeVersion: 1, tabletopId: 1, + archiveId: 1, } }); try { assertViewPermission(permissionCreature, userId) } catch (e) { return [] } + if (permissionCreature && permissionCreature.archiveId) { + restoreSoftArchive(permissionCreature._id); + } loadCreature(creatureId, self); if (permissionCreature?.computeVersion !== VERSION && computation.firstRun) { try { diff --git a/app/packages/redis-oplog b/app/packages/redis-oplog new file mode 160000 index 000000000..83e302c15 --- /dev/null +++ b/app/packages/redis-oplog @@ -0,0 +1 @@ +Subproject commit 83e302c15456d6744047c50fc8d1add9e739b001 diff --git a/app/server/main.js b/app/server/main.js index eeab11f8c..e606e48a9 100644 --- a/app/server/main.js +++ b/app/server/main.js @@ -8,6 +8,7 @@ import '/imports/server/config/SyncedCronConfig'; import '/imports/server/config/redisCaching'; import '/imports/server/publications/index'; import '/imports/server/cron/deleteSoftRemovedDocuments'; +import '/imports/server/cron/archiveOldCreatures'; import '/imports/api/parenting/organizeMethods'; import '/imports/api/users/patreon/updatePatreonOnLogin'; import '/imports/migrations/server/index'; diff --git a/docker-compose.yml b/docker-compose.yml index b6f40f3ed..17909a547 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,8 @@ services: - MONGO_URL=mongodb://meteor:meteor@dicecloud-db:27017 - PORT=3000 - NODE_ENV=production - - METEOR_SETTINGS={"public":{"environment":"production","disablePatreon":true}} + - METEOR_SETTINGS={"public":{"environment":"production","disablePatreon":true},"archiveAfterDays":100} - MAIL_URL=smtp://EMAIL:PASSWORD@SERVER:PORT ports: - - '3000:3000' #The internal port should match the port set in the environmental variables \ No newline at end of file + - '3000:3000' #The internal port should match the port set in the environmental variables +