diff --git a/app/.gitignore b/app/.gitignore index 2df4ae3a..463207c9 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -8,4 +8,5 @@ private/oldClient nohup.out node_modules dump +.idea/ .cache diff --git a/app/Routes/API.js b/app/Routes/API.js index 7df32907..863fdf91 100644 --- a/app/Routes/API.js +++ b/app/Routes/API.js @@ -1,91 +1,268 @@ -Router.map(function() { - this.route("vmixCharacter", { - path: "/vmix-character/:_id/", - where: "server", - action: function() { - this.response.setHeader("Content-Type", "application/json"); - var query = this.params.query; - var key = query && query.key; - ifKeyValid(key, this.response, "vmixCharacter", () => - this.response.end(vMixCharacter(this.params._id)) - ); - }, - }); - this.route("vmixParty", { - path: "/vmix-party/:_id/", - where: "server", - action: function() { - this.response.setHeader("Content-Type", "application/json"); - var query = this.params.query; - var key = query && query.key; - ifKeyValid(key, this.response, "vmixParty", () => - this.response.end(vMixParty(this.params._id)) - ); - }, - }); +Router.map(function () { + this.route("vmixCharacter", { + path: "/vmix-character/:_id/", + where: "server", + action: function () { + this.response.setHeader("Content-Type", "application/json"); + var query = this.params.query; + var key = query && query.key; + ifKeyValid(key, this.response, "vmixCharacter", () => + this.response.end(vMixCharacter(this.params._id)) + ); + }, + }); + this.route("vmixParty", { + path: "/vmix-party/:_id/", + where: "server", + action: function () { + this.response.setHeader("Content-Type", "application/json"); + var query = this.params.query; + var key = query && query.key; + ifKeyValid(key, this.response, "vmixParty", () => + this.response.end(vMixParty(this.params._id)) + ); + }, + }); - this.route("jsonCharacterSheet", { - path: "/character/:_id/json", - where: "server", - action: function() { - this.response.setHeader("Content-Type", "application/json"); - var query = this.params.query; - var key = query && query.key; - ifKeyValid(key, this.response, "jsonCharacterSheet", () => { - if (canViewCharacter(this.params._id, userIdFromKey(key))){ - this.response.end(JSONExport(this.params._id)) - } else { - this.response.writeHead(403, "You do not have permission to view this character"); - this.response.end(); - } - } - ); - }, - }); + this.route("jsonCharacterSheet", { // GET /character/:_id/json?key=:key + path: "/character/:_id/json", + where: "server", + action: function () { + this.response.setHeader("Content-Type", "application/json"); + var query = this.params.query; + var key = query && query.key; + ifKeyValid(key, this.response, "jsonCharacterSheet", () => { + if (canViewCharacter(this.params._id, userIdFromKey(key))) { + this.response.end(JSONExport(this.params._id)) + } else { + this.response.writeHead(403, "You do not have permission to view this character"); + this.response.end(); + } + } + ); + }, + }); + + this.route("getUserId", { // GET /api/user?username=:un&key=:key + path: "/api/user", + where: "server", + action: function () { + this.response.setHeader("Content-Type", "application/json"); + var query = this.params.query; + var key = query && query.key; + var username = query && query.username; + ifKeyValid(key, this.response, "getUserId", () => { + Meteor.call("getUserId", username, (err, result) => { + if (err) { + console.error(err); + this.response.writeHead(404, "User not found"); + this.response.end(); + } else { + console.log(result); + this.response.end(JSON.stringify({id: result})); + } + }); + }); + } + }); + + this.route("addSpellsToCharacter", { // POST /api/character/:_id/spellList/:listId + path: "/api/character/:_id/spellList/:listId", + where: "server" + }).post(function () { + const key = startPOSTResponse(this); + const spells = this.request.body; + const charId = this.params._id; + const listId = this.params.listId; + Meteor.call("insertSpells", key, charId, listId, spells, (err, res) => { + if (err) { + this.response.writeHead(err.error, err.reason); + this.response.end(err.details); + } else { + this.response.end(JSON.stringify(res)); + } + }); + }); + + this.route("createCharacter", { // POST /api/character + path: "/api/character", + where: "server" + }).post(function () { + const key = startPOSTResponse(this); + const character = this.request.body; + Meteor.call("insertCharacter", key, character, (err, res) => { + if (err) { + this.response.writeHead(err.error, err.reason); + this.response.end(err.details); + } else { + this.response.end(JSON.stringify(res)); + } + }); + }); + + this.route("deleteCharacter", { // DELETE /api/character/:_id + path: "/api/character/:_id", + where: "server" + }).delete(function () { + const key = startPOSTResponse(this); + const charId = this.params._id; + Meteor.call("deleteCharacter", key, charId, (err, res) => { + if (err) { + this.response.writeHead(err.error, err.reason); + this.response.end(err.details); + } else { + this.response.end(JSON.stringify(res)); + } + }); + }); + + this.route("transferCharacterOwnership", { // PUT /api/character/:_id/owner + path: "/api/character/:_id/owner", + where: "server" + }).put(function () { + const key = startPOSTResponse(this); + const charId = this.params._id; + const ownerId = this.request.body['id']; + Meteor.call("transferCharacterOwnership", key, charId, ownerId, (err, res) => { + if (err) { + this.response.writeHead(err.error, err.reason); + this.response.end(err.details); + } else { + this.response.end(JSON.stringify(res)); + } + }); + }); + + this.route("insertFeatures", { // POST /api/character/:_id/feature + path: "/api/character/:_id/feature", + where: "server", + }).post(function () { + const key = startPOSTResponse(this); + const charId = this.params._id; + const features = this.request.body; + Meteor.call("insertFeatures", key, charId, features, (err, res) => { + if (err) { + this.response.writeHead(err.error, err.reason); + this.response.end(err.details); + } else { + this.response.end(JSON.stringify(res)); + } + }); + }); + + this.route("insertProfs", { // POST /api/character/:_id/prof + path: "/api/character/:_id/prof", + where: "server", + }).post(function () { + const key = startPOSTResponse(this); + const charId = this.params._id; + const profs = this.request.body; + Meteor.call("insertProfs", key, charId, profs, (err, res) => { + if (err) { + this.response.writeHead(err.error, err.reason); + this.response.end(err.details); + } else { + this.response.end(JSON.stringify(res)); + } + }); + }); + + this.route("insertEffects", { // POST /api/character/:_id/effect + path: "/api/character/:_id/effect", + where: "server", + }).post(function () { + const key = startPOSTResponse(this); + const charId = this.params._id; + const effects = this.request.body; + Meteor.call("insertEffects", key, charId, effects, (err, res) => { + if (err) { + this.response.writeHead(err.error, err.reason); + this.response.end(err.details); + } else { + this.response.end(JSON.stringify(res)); + } + }); + }); + + this.route("insertClasses", { // POST /api/character/:_id/class + path: "/api/character/:_id/class", + where: "server", + }).post(function () { + const key = startPOSTResponse(this); + const charId = this.params._id; + const classes = this.request.body; + Meteor.call("insertClasses", key, charId, classes, (err, res) => { + if (err) { + this.response.writeHead(err.error, err.reason); + this.response.end(err.details); + } else { + this.response.end(JSON.stringify(res)); + } + }); + }); }); -var ifKeyValid = function(apiKey, response, method, callback){ - if (!apiKey){ - response.writeHead(403, "You must use an api key to access this api"); - response.end(); - } else if (!isKeyValid(apiKey)){ - response.writeHead(403, "API key is invalid"); - response.end(); - } else if (isRateLimited(apiKey, method)){ - response.writeHead(429, "Too many requests"); - response.end(JSON.stringify({ - "timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset - })); - } else { - rateLimiter.increment({apiKey: apiKey, method: method}) - callback(); - } +const startPOSTResponse = function (request) { + request.response.setHeader("Content-Type", "application/json"); + const header = request.request.headers; + return header && header['authorization']; }; -var isKeyValid = function(apiKey){ - var user = Meteor.users.findOne({apiKey}); - if (!user) return false; - var blackListed = Blacklist.findOne({userId: user._id}); - return !blackListed; +var ifKeyValid = function (apiKey, response, method, callback) { + if (!apiKey) { + response.writeHead(403, "You must use an api key to access this api"); + response.end(); + } else if (!isKeyValid(apiKey)) { + response.writeHead(403, "API key is invalid"); + response.end(); + } else if (isRateLimited(apiKey, method)) { + response.writeHead(429, "Too many requests"); + response.end(JSON.stringify({ + "timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset + })); + } else { + rateLimiter.increment({apiKey: apiKey, method: method}); + callback(); + } }; -var userIdFromKey = function(apiKey){ - var user = Meteor.users.findOne({apiKey}); // we know user exists from isKeyValid - return user._id; -} +isKeyValid = function (apiKey) { + var user = Meteor.users.findOne({apiKey}); + if (!user) return false; + var blackListed = Blacklist.findOne({userId: user._id}); + return !blackListed; +}; -var rateLimiter = new RateLimiter(); -rateLimiter.addRule({apiKey: String}, 5, 5000); +userIdFromKey = function (apiKey) { + var user = Meteor.users.findOne({apiKey}); // we know user exists from isKeyValid + return user._id; +}; + +rateLimiter = new RateLimiter(); +// global limit +rateLimiter.addRule({apiKey: String}, 10, 1000); + +// vmix stuff rateLimiter.addRule({apiKey: String, method: "vmixCharacter"}, 2, 10000); rateLimiter.addRule({apiKey: String, method: "vmixParty"}, 2, 10000); -rateLimiter.addRule({apiKey: String, method: "jsonCharacterSheet"}, 5, 5000); -var isRateLimited = function(apiKey, method){ - const limited = !rateLimiter.check({apiKey: apiKey, method: method}).allowed - if (limited) { - console.log(`Rate limit hit by API key ${apiKey}`); - return true; - } else { - return false; - } +// bot API endpoints +rateLimiter.addRule({apiKey: String, method: "jsonCharacterSheet"}, 5, 5000); +rateLimiter.addRule({apiKey: String, method: "getUserId"}, 5, 5000); +rateLimiter.addRule({apiKey: String, method: "addSpellsToCharacter"}, 5, 5000); +rateLimiter.addRule({apiKey: String, method: "createCharacter"}, 5, 5000); +rateLimiter.addRule({apiKey: String, method: "transferCharacterOwnership"}, 5, 5000); +rateLimiter.addRule({apiKey: String, method: "insertFeatures"}, 5, 5000); +rateLimiter.addRule({apiKey: String, method: "insertProfs"}, 5, 5000); +rateLimiter.addRule({apiKey: String, method: "insertEffects"}, 5, 5000); +rateLimiter.addRule({apiKey: String, method: "insertClasses"}, 5, 5000); + +isRateLimited = function (apiKey, method) { + const limited = !rateLimiter.check({apiKey: apiKey, method: method}).allowed; + if (limited) { + console.log(`Rate limit hit by API key ${apiKey}`); + return true; + } else { + return false; + } }; diff --git a/app/lib/functions/api.js b/app/lib/functions/api.js new file mode 100644 index 00000000..34022cd6 --- /dev/null +++ b/app/lib/functions/api.js @@ -0,0 +1,471 @@ +/** + * @return {string} + */ +JSONExport = function (charId) { + const character = { + "attacks": Attacks.find({charId: charId}).fetch(), + "characters": Characters.find({_id: charId}).fetch(), + "classes": Classes.find({charId: charId}).fetch(), + "containers": Containers.find({charId: charId}).fetch(), + "effects": Effects.find({charId: charId}).fetch(), + "experience": Experiences.find({charId: charId}).fetch(), + "features": Features.find({charId: charId}).fetch(), + "items": Items.find({charId: charId}).fetch(), + "notes": Notes.find({charId: charId}).fetch(), + "proficiencies": Proficiencies.find({charId: charId}).fetch(), + "spellLists": SpellLists.find({charId: charId}).fetch(), + "spells": Spells.find({charId: charId}).fetch() + }; + return JSON.stringify(character); +}; + +Meteor.methods({ + "insertSpells": function (key, charId, listId, spells) { + if (Meteor.isClient) return; + assertCanEdit(key, charId, "addSpellsToCharacter"); + let ids = []; + let error; + for (let spell of spells) { + spell.charId = charId; + try { + Schemas.Spell.clean(spell); + } catch (e) { + // console.log(e); + error = e.error; + } + if (!error) { + spell.parent = {id: listId, collection: "SpellLists"}; + let id = Spells.direct.insert(spell, (err) => { + if (err) { + error = err.message; + } + }); + // console.log(id); + ids.push(id); + } else { + break; + } + } + if (error) { + throw new Meteor.Error(400, "Failed to insert one or more spells", JSON.stringify({ + err: error, + inserted: ids + })); + } else { + return ids; + } + }, + + "insertCharacter": function (key, character) { + if (Meteor.isClient) return; + assertAuthorized(key, "createCharacter"); + let error, id; + + character.owner = userIdFromKey(key); + try { + Schemas.Character.clean(character); + } catch (e) { + console.log(e); + error = e.error; + } + if (!error) { + id = Characters.direct.insert(character, (err) => { + if (err) + error = err.message; + }); + afterCharacterInsert(id); + return {id: id}; + } else { + throw new Meteor.Error(400, "Failed to insert character", JSON.stringify({err: error})); + } + }, + + "deleteCharacter": function (key, charId) { + if (Meteor.isClient) return; + assertAuthorized(key, "deleteCharacter"); + if (isOwner(charId, userIdFromKey(key))) { + let error; + + Characters.direct.remove({_id: charId}, (err) => { + if (err) + error = err.message; + }); + if (error) { + throw new Meteor.Error(400, "Failed to delete character", JSON.stringify({err: error})); + } else { + return {success: true}; + } + } else { + throw new Meteor.Error(403, "You do not have permission to delete the requested character"); + } + }, + + "transferCharacterOwnership": function (key, charId, newOwner) { + if (Meteor.isClient) return; + assertAuthorized(key, "transferCharacterOwnership"); + if (isOwner(charId, userIdFromKey(key))) { + let error; + Characters.direct.update({_id: charId}, {"$set": {owner: newOwner}}, null, + (err) => { + if (err) + error = err.message; + }); + + if (error) { + throw new Meteor.Error(400, "Failed to update character", JSON.stringify({err: error})); + } else { + return {success: true}; + } + } else { + throw new Meteor.Error(403, "You do not have permission to transfer the requested character"); + } + }, + + "insertFeatures": function (key, charId, features) { + if (Meteor.isClient) return; + assertCanEdit(key, charId, "insertFeatures"); + let ids = []; + let error; + for (let feature of features) { + feature.charId = charId; + try { + Schemas.Feature.clean(feature); + } catch (e) { + error = e.error; + } + if (!error) { + let id = Features.direct.insert(feature, (err) => { + if (err) { + error = err.message; + } + }); + ids.push(id); + } else { + break; + } + } + if (error) { + throw new Meteor.Error(400, "Failed to insert one or more features", JSON.stringify({ + err: error, + inserted: ids + })); + } else { + return ids; + } + + }, + + "insertProfs": function (key, charId, profs) { + if (Meteor.isClient) return; + assertCanEdit(key, charId, "insertProfs"); + let ids = []; + let error; + for (let prof of profs) { + prof.charId = charId; + try { + Schemas.Proficiency.clean(prof, {filter: false}); + } catch (e) { + error = e.error; + } + if (!error) { + let id = Proficiencies.direct.insert(prof, (err) => { + if (err) { + error = err.message; + } + }); + ids.push(id); + } else { + break; + } + } + if (error) { + throw new Meteor.Error(400, "Failed to insert one or more profs", JSON.stringify({ + err: error, + inserted: ids + })); + } else { + return ids; + } + }, + + "insertEffects": function (key, charId, effects) { + if (Meteor.isClient) return; + assertCanEdit(key, charId, "insertEffects"); + let ids = []; + let error; + for (let effect of effects) { + effect.charId = charId; + try { + Schemas.Effect.clean(effect, {filter: false}); + } catch (e) { + error = e.error; + } + if (!error) { + let id = Effects.direct.insert(effect, (err) => { + if (err) { + error = err.message; + } + }); + ids.push(id); + } else { + break; + } + } + if (error) { + throw new Meteor.Error(400, "Failed to insert one or more effects", JSON.stringify({ + err: error, + inserted: ids + })); + } else { + return ids; + } + }, + + "insertClasses": function (key, charId, klasses) { + if (Meteor.isClient) return; + assertCanEdit(key, charId, "insertClasses"); + let ids = []; + let error; + for (let klass of klasses) { + klass.charId = charId; + try { + Schemas.Class.clean(klass); + } catch (e) { + error = e.error; + } + if (!error) { + let id = Classes.direct.insert(klass, (err) => { + if (err) { + error = err.message; + } + }); + ids.push(id); + } else { + break; + } + } + if (error) { + throw new Meteor.Error(400, "Failed to insert one or more classes", JSON.stringify({ + err: error, + inserted: ids + })); + } else { + return ids; + } + } +}); + +var assertCanEdit = function (key, charId, method) { + if (canEditCharacter(charId, userIdFromKey(key))) { + assertAuthorized(key, method); + } else { + throw new Meteor.Error(403, "You do not have permission to edit the requested character"); + } +}; + +var assertAuthorized = function (apiKey, method) { + if (!apiKey) { + throw new Meteor.Error(403, "You must use an api key to access this api"); + } else if (!isKeyValid(apiKey)) { + throw new Meteor.Error(403, "API key is invalid"); + } else if (isRateLimited(apiKey, method)) { + throw new Meteor.Error(429, "Too many requests", JSON.stringify({ + "timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset + })); + } else { + rateLimiter.increment({apiKey: apiKey, method: method}); + } +}; + +var afterCharacterInsert = function (charId) { + // Effects + Effects.direct.insert({ + charId: charId, + name: "Constitution modifier for each level", + stat: "hitPoints", + operation: "add", + calculation: "level * constitutionMod", + parent: { + id: charId, + collection: "Characters", + group: "Inate", + }, + }); + Effects.direct.insert({ + charId: charId, + name: "Proficiency bonus by level", + stat: "proficiencyBonus", + operation: "add", + calculation: "floor(level / 4 + 1.75)", + parent: { + id: charId, + collection: "Characters", + group: "Inate", + }, + }); + Effects.direct.insert({ + charId: charId, + name: "Dexterity Armor Bonus", + stat: "armor", + operation: "add", + calculation: "dexterityArmor", + parent: { + id: charId, + collection: "Characters", + group: "Inate", + }, + }); + Effects.direct.insert({ + charId: charId, + name: "Natural Armor", + stat: "armor", + operation: "base", + value: 10, + parent: { + id: charId, + collection: "Characters", + group: "Inate", + }, + }); + Effects.direct.insert({ + charId: charId, + name: "Natural Carrying Capacity", + stat: "carryMultiplier", + operation: "base", + value: "1", + parent: { + id: charId, + collection: "Characters", + group: "Inate", + }, + }); + // Features + let featureId = Features.direct.insert({ + name: "Base Ability Scores", + charId: charId, + enabled: true, + alwaysEnabled: true, + }); + Effects.direct.insert({ + stat: "strength", + charId: charId, + parent: { + id: featureId, + collection: "Features", + }, + operation: "base", + value: 10, + enabled: true, + }); + Effects.direct.insert({ + stat: "dexterity", + charId: charId, + parent: { + id: featureId, + collection: "Features", + }, + operation: "base", + value: 10, + enabled: true, + }); + Effects.direct.insert({ + stat: "constitution", + charId: charId, + parent: { + id: featureId, + collection: "Features", + }, + operation: "base", + value: 10, + enabled: true, + }); + Effects.direct.insert({ + stat: "intelligence", + charId: charId, + parent: { + id: featureId, + collection: "Features", + }, + operation: "base", + value: 10, + enabled: true, + }); + Effects.direct.insert({ + stat: "wisdom", + charId: charId, + parent: { + id: featureId, + collection: "Features", + }, + operation: "base", + value: 10, + enabled: true, + }); + Effects.direct.insert({ + stat: "charisma", + charId: charId, + parent: { + id: featureId, + collection: "Features", + }, + operation: "base", + value: 10, + enabled: true, + }); + // Items + let containerId = Containers.direct.insert({ + name: "Coin Pouch", + charId: charId, + isCarried: true, + description: "A sturdy pouch for coins", + color: "d", + }); + Items.direct.insert({ + name: "Gold piece", + plural: "Gold pieces", + charId: charId, + quantity: 0, + weight: 0.02, + value: 1, + color: "n", + parent: { + id: containerId, + collection: "Containers", + }, + settings: { + showIncrement: true, + }, + }); + Items.direct.insert({ + name: "Silver piece", + plural: "Silver pieces", + charId: charId, + quantity: 0, + weight: 0.02, + value: 0.1, + color: "q", + parent: { + id: containerId, + collection: "Containers", + }, + settings: { + showIncrement: true, + }, + }); + Items.direct.insert({ + name: "Copper piece", + plural: "Copper pieces", + charId: charId, + quantity: 0, + weight: 0.02, + value: 0.01, + color: "s", + parent: { + id: containerId, + collection: "Containers", + }, + settings: { + showIncrement: true, + }, + }); +}; diff --git a/app/lib/functions/characterJSON.js b/app/lib/functions/characterJSON.js deleted file mode 100644 index 861bb796..00000000 --- a/app/lib/functions/characterJSON.js +++ /dev/null @@ -1,17 +0,0 @@ -JSONExport = function(charId) { - var character = { - "attacks": Attacks.find({charId: charId}).fetch(), - "characters": Characters.find({_id: charId}).fetch(), - "classes": Classes.find({charId: charId}).fetch(), - "containers": Containers.find({charId: charId}).fetch(), - "effects": Effects.find({charId: charId}).fetch(), - "experience": Experiences.find({charId: charId}).fetch(), - "features": Features.find({charId: charId}).fetch(), - "items": Items.find({charId: charId}).fetch(), - "notes": Notes.find({charId: charId}).fetch(), - "proficiencies": Proficiencies.find({charId: charId}).fetch(), - "spellLists": SpellLists.find({charId: charId}).fetch(), - "spells": Spells.find({charId: charId}).fetch() - }; - return JSON.stringify(character); -} diff --git a/app/lib/functions/permissions.js b/app/lib/functions/permissions.js index c6b72ff1..db29df8f 100644 --- a/app/lib/functions/permissions.js +++ b/app/lib/functions/permissions.js @@ -1,3 +1,10 @@ +isOwner = function(charId, userId) { + userId = userId || Meteor.userId(); + var char = Characters.findOne(charId, {fields: {owner: 1}}); + if (!char) return true; + return (userId === char.owner); +}; + canEditCharacter = function(charId, userId){ userId = userId || Meteor.userId(); var char = Characters.findOne(charId, {fields: {owner: 1, writers: 1}});