diff --git a/app/.meteor/packages b/app/.meteor/packages index 258a617b..e70e1092 100644 --- a/app/.meteor/packages +++ b/app/.meteor/packages @@ -53,3 +53,4 @@ akryum:vue-component akryum:vue-sass percolate:migrations meteortesting:mocha +ostrio:files diff --git a/app/.meteor/versions b/app/.meteor/versions index bcc20581..b7276f60 100644 --- a/app/.meteor/versions +++ b/app/.meteor/versions @@ -85,6 +85,8 @@ oauth@1.3.2 oauth2@1.3.0 ongoworks:speakingurl@9.0.0 ordered-dict@1.1.0 +ostrio:cookies@2.7.0 +ostrio:files@2.0.1 patreon-oauth@0.1.0 peerlibrary:assert@0.3.0 peerlibrary:check-extension@0.7.0 diff --git a/app/imports/api/creature/archive/ArchiveCreatureFiles.js b/app/imports/api/creature/archive/ArchiveCreatureFiles.js new file mode 100644 index 00000000..fc3555a3 --- /dev/null +++ b/app/imports/api/creature/archive/ArchiveCreatureFiles.js @@ -0,0 +1,18 @@ +import { FilesCollection } from 'meteor/ostrio:files'; + +const ArchiveCreatureFiles = new FilesCollection({ + collectionName: 'archiveCreatureFiles', + allowClientCode: false, // Disallow remove files from Client + storagePath: '/DiceCloud/uploads/', + onBeforeUpload(file) { + // Allow upload files under 10MB, and only in json format + if (file.size > 10485760) { + return 'Please upload with size equal or less than 10MB'; + } + if (!/json/i.test(file.extension)){ + return 'Please upload only a JSON file'; + } + } +}); + +export default ArchiveCreatureFiles; diff --git a/app/imports/api/creature/archive/methods/archiveCreatureToFile.js b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js new file mode 100644 index 00000000..76a99d1a --- /dev/null +++ b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js @@ -0,0 +1,70 @@ +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 { assertOwnership } from '/imports/api/creature/creatures/creaturePermissions.js'; +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'; + +export function getArchiveObj(creatureId){ + // Build the archive document + const creature = Creatures.findOne(creatureId); + const properties = CreatureProperties.find({'ancestors.id': creatureId}).fetch(); + const experiences = Experiences.find({creatureId}).fetch(); + const logs = CreatureLogs.find({creatureId}).fetch(); + let archiveCreature = { + meta: { + archiveDate: new Date(), + schemaVersion: SCHEMA_VERSION, + }, + creature, + properties, + experiences, + logs, + }; + + return archiveCreature; +} + +export function writeArchiveCreatureFile(archive){ + const buffer = Buffer.from(JSON.stringify(archive, null, 2)); + return ArchiveCreatureFiles.write(buffer, { + fileName: `${archive.creature.name || archive.creature._id}.json`, + type: 'application/json', + userId: archive.creature.owner, + meta: { + schemaVersion: SCHEMA_VERSION, + creatureId: archive.creature._id, + creatureName: archive.creature.name, + }, + }); +} + +const archiveCreatureToFile = new ValidatedMethod({ + name: 'Creatures.methods.archiveCreatureToFile', + validate: new SimpleSchema({ + 'creatureId': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 10, + timeInterval: 5000, + }, + async run({creatureId}) { + assertOwnership(creatureId, this.userId); + if (Meteor.isServer){ + const archive = getArchiveObj(creatureId); + writeArchiveCreatureFile(archive); + } + removeCreatureWork(creatureId); + }, +}); + +export default archiveCreatureToFile; diff --git a/app/imports/api/creature/archive/methods/index.js b/app/imports/api/creature/archive/methods/index.js index 62d8bd8d..025527fd 100644 --- a/app/imports/api/creature/archive/methods/index.js +++ b/app/imports/api/creature/archive/methods/index.js @@ -1,2 +1,4 @@ import '/imports/api/creature/archive/methods/archiveCreatures.js'; +import '/imports/api/creature/archive/methods/archiveCreatureToFile.js'; import '/imports/api/creature/archive/methods/restoreCreatures.js'; +import '/imports/api/creature/archive/methods/restoreCreatureFromFile.js'; diff --git a/app/imports/api/creature/archive/methods/readFile.js b/app/imports/api/creature/archive/methods/readFile.js new file mode 100644 index 00000000..412bd643 --- /dev/null +++ b/app/imports/api/creature/archive/methods/readFile.js @@ -0,0 +1,6 @@ +import { promises as fs } from 'fs'; + +// Read a file and return the result +export default function read(file){ + return fs.readFile(file.path, 'utf8'); +} diff --git a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js new file mode 100644 index 00000000..8ae3ba88 --- /dev/null +++ b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js @@ -0,0 +1,80 @@ +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 readFile from '/imports/api/creature/archive/methods/readFile.js'; + +function restoreCreature(file, archive){ + if (SCHEMA_VERSION < file.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, file.meta.schemaVersion); + + // Insert the creature sub documents + // They still have their original _id's + Creatures.insert(archive.creature); + 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); + } + } catch (e) { + // If the above fails, delete the inserted creature + removeCreatureWork(archive.creature._id); + throw e; + } +} + +const restoreCreaturefromFile = new ValidatedMethod({ + name: 'Creatures.methods.restoreCreaturefromFile', + validate: new SimpleSchema({ + 'fileId': { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 10, + 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'); + } + if (Meteor.isServer){ + // Read the file data + const string = await readFile(file); + const archive = JSON.parse(string); + restoreCreature(file, archive); + //Remove the archive once the restore succeeded + ArchiveCreatureFiles.remove({_id: fileId}) + } + }, +}); + +export default restoreCreaturefromFile; diff --git a/app/imports/migrations/server/2.0-beta.33-dbv1.js b/app/imports/migrations/server/2.0-beta.33-dbv1.js index 53972a54..76b35047 100644 --- a/app/imports/migrations/server/2.0-beta.33-dbv1.js +++ b/app/imports/migrations/server/2.0-beta.33-dbv1.js @@ -19,8 +19,14 @@ Migrations.add({ }); function migrate({reversed} = {}){ + console.log('unarchiving all characters from database archive'); + // TODO + console.log('migrating creature properties'); migrateCollection({collection: CreatureProperties, reversed}); + console.log('migrating library nodes') migrateCollection({collection: LibraryNodes, reversed}); + console.log('archiving characters to file system archive'); + // TODO } function migrateCollection({collection, reversed}){ diff --git a/app/imports/server/publications/archiveFiles.js b/app/imports/server/publications/archiveFiles.js new file mode 100644 index 00000000..8da1130e --- /dev/null +++ b/app/imports/server/publications/archiveFiles.js @@ -0,0 +1,7 @@ +import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles.js'; + +Meteor.publish('archiveCreatureFiles', function () { + return ArchiveCreatureFiles.find({ + userId: this.userId, + }).cursor; +}); diff --git a/app/imports/server/publications/index.js b/app/imports/server/publications/index.js index c58f6188..3bdc770a 100644 --- a/app/imports/server/publications/index.js +++ b/app/imports/server/publications/index.js @@ -10,3 +10,4 @@ import '/imports/server/publications/slotFillers.js'; import '/imports/server/publications/ownedDocuments.js'; import '/imports/server/publications/archivedCreatures.js'; import '/imports/server/publications/searchLibraryNodes.js'; +import '/imports/server/publications/archiveFiles.js'; diff --git a/app/imports/ui/creature/archive/ArchiveDialog.vue b/app/imports/ui/creature/archive/ArchiveDialog.vue index 03b687f2..0182b339 100644 --- a/app/imports/ui/creature/archive/ArchiveDialog.vue +++ b/app/imports/ui/creature/archive/ArchiveDialog.vue @@ -60,12 +60,13 @@