Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fbb006783 | ||
|
|
2253672f43 | ||
|
|
ed6d557f8a | ||
|
|
4d642b56bb | ||
|
|
436c5bb785 | ||
|
|
cb71f6d380 | ||
|
|
2f04d9ec1c | ||
|
|
40c54524a7 | ||
|
|
b890a3b11e | ||
|
|
c9242a95f3 | ||
|
|
fedda62c7c | ||
|
|
612575d0e6 | ||
|
|
d1d22c0d89 | ||
|
|
b94f5ebb4b |
1
app/.gitignore
vendored
1
app/.gitignore
vendored
@@ -8,4 +8,5 @@ private/oldClient
|
|||||||
nohup.out
|
nohup.out
|
||||||
node_modules
|
node_modules
|
||||||
dump
|
dump
|
||||||
|
.idea/
|
||||||
.cache
|
.cache
|
||||||
|
|||||||
26
app/Model/Meta/PatreonPosts.js
Normal file
26
app/Model/Meta/PatreonPosts.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
PatreonPosts = new Mongo.Collection("patreonPosts");
|
||||||
|
|
||||||
|
Schemas.PatreonPosts = new SimpleSchema({
|
||||||
|
link: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
dateAdded: {
|
||||||
|
type: Date,
|
||||||
|
autoValue(){
|
||||||
|
return new Date();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
PatreonPosts.attachSchema(Schemas.PatreonPosts);
|
||||||
|
|
||||||
|
PatreonPosts.allow({
|
||||||
|
insert: function(userId, doc) {
|
||||||
|
var user = Meteor.users.findOne(userId);
|
||||||
|
if (user) return _.contains(user.roles, "admin");
|
||||||
|
},
|
||||||
|
remove: function(userId, doc) {
|
||||||
|
var user = Meteor.users.findOne(userId);
|
||||||
|
if (user) return _.contains(user.roles, "admin");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -70,6 +70,10 @@ Schemas.User = new SimpleSchema({
|
|||||||
index: 1,
|
index: 1,
|
||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
|
lastPatreonPostClicked: {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
Meteor.users.attachSchema(Schemas.User);
|
Meteor.users.attachSchema(Schemas.User);
|
||||||
@@ -107,3 +111,11 @@ if (Meteor.isServer) Meteor.methods({
|
|||||||
Meteor.users.update(this.userId, {$set: {apiKey}});
|
Meteor.users.update(this.userId, {$set: {apiKey}});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Meteor.methods({
|
||||||
|
clickPatreonPost(link) {
|
||||||
|
Meteor.users.update(this.userId, {$set: {
|
||||||
|
lastPatreonPostClicked: link
|
||||||
|
}});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,91 +1,268 @@
|
|||||||
Router.map(function() {
|
Router.map(function () {
|
||||||
this.route("vmixCharacter", {
|
this.route("vmixCharacter", {
|
||||||
path: "/vmix-character/:_id/",
|
path: "/vmix-character/:_id/",
|
||||||
where: "server",
|
where: "server",
|
||||||
action: function() {
|
action: function () {
|
||||||
this.response.setHeader("Content-Type", "application/json");
|
this.response.setHeader("Content-Type", "application/json");
|
||||||
var query = this.params.query;
|
var query = this.params.query;
|
||||||
var key = query && query.key;
|
var key = query && query.key;
|
||||||
ifKeyValid(key, this.response, "vmixCharacter", () =>
|
ifKeyValid(key, this.response, "vmixCharacter", () =>
|
||||||
this.response.end(vMixCharacter(this.params._id))
|
this.response.end(vMixCharacter(this.params._id))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.route("vmixParty", {
|
this.route("vmixParty", {
|
||||||
path: "/vmix-party/:_id/",
|
path: "/vmix-party/:_id/",
|
||||||
where: "server",
|
where: "server",
|
||||||
action: function() {
|
action: function () {
|
||||||
this.response.setHeader("Content-Type", "application/json");
|
this.response.setHeader("Content-Type", "application/json");
|
||||||
var query = this.params.query;
|
var query = this.params.query;
|
||||||
var key = query && query.key;
|
var key = query && query.key;
|
||||||
ifKeyValid(key, this.response, "vmixParty", () =>
|
ifKeyValid(key, this.response, "vmixParty", () =>
|
||||||
this.response.end(vMixParty(this.params._id))
|
this.response.end(vMixParty(this.params._id))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.route("jsonCharacterSheet", {
|
this.route("jsonCharacterSheet", { // GET /character/:_id/json?key=:key
|
||||||
path: "/character/:_id/json",
|
path: "/character/:_id/json",
|
||||||
where: "server",
|
where: "server",
|
||||||
action: function() {
|
action: function () {
|
||||||
this.response.setHeader("Content-Type", "application/json");
|
this.response.setHeader("Content-Type", "application/json");
|
||||||
var query = this.params.query;
|
var query = this.params.query;
|
||||||
var key = query && query.key;
|
var key = query && query.key;
|
||||||
ifKeyValid(key, this.response, "jsonCharacterSheet", () => {
|
ifKeyValid(key, this.response, "jsonCharacterSheet", () => {
|
||||||
if (canViewCharacter(this.params._id, userIdFromKey(key))){
|
if (canViewCharacter(this.params._id, userIdFromKey(key))) {
|
||||||
this.response.end(JSONExport(this.params._id))
|
this.response.end(JSONExport(this.params._id))
|
||||||
} else {
|
} else {
|
||||||
this.response.writeHead(403, "You do not have permission to view this character");
|
this.response.writeHead(403, "You do not have permission to view this character");
|
||||||
this.response.end();
|
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){
|
const startPOSTResponse = function (request) {
|
||||||
if (!apiKey){
|
request.response.setHeader("Content-Type", "application/json");
|
||||||
response.writeHead(403, "You must use an api key to access this api");
|
const header = request.request.headers;
|
||||||
response.end();
|
return header && header['authorization'];
|
||||||
} 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 isKeyValid = function(apiKey){
|
var ifKeyValid = function (apiKey, response, method, callback) {
|
||||||
var user = Meteor.users.findOne({apiKey});
|
if (!apiKey) {
|
||||||
if (!user) return false;
|
response.writeHead(403, "You must use an api key to access this api");
|
||||||
var blackListed = Blacklist.findOne({userId: user._id});
|
response.end();
|
||||||
return !blackListed;
|
} 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){
|
isKeyValid = function (apiKey) {
|
||||||
var user = Meteor.users.findOne({apiKey}); // we know user exists from isKeyValid
|
var user = Meteor.users.findOne({apiKey});
|
||||||
return user._id;
|
if (!user) return false;
|
||||||
}
|
var blackListed = Blacklist.findOne({userId: user._id});
|
||||||
|
return !blackListed;
|
||||||
|
};
|
||||||
|
|
||||||
var rateLimiter = new RateLimiter();
|
userIdFromKey = function (apiKey) {
|
||||||
rateLimiter.addRule({apiKey: String}, 5, 5000);
|
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: "vmixCharacter"}, 2, 10000);
|
||||||
rateLimiter.addRule({apiKey: String, method: "vmixParty"}, 2, 10000);
|
rateLimiter.addRule({apiKey: String, method: "vmixParty"}, 2, 10000);
|
||||||
rateLimiter.addRule({apiKey: String, method: "jsonCharacterSheet"}, 5, 5000);
|
|
||||||
|
|
||||||
var isRateLimited = function(apiKey, method){
|
// bot API endpoints
|
||||||
const limited = !rateLimiter.check({apiKey: apiKey, method: method}).allowed
|
rateLimiter.addRule({apiKey: String, method: "jsonCharacterSheet"}, 5, 5000);
|
||||||
if (limited) {
|
rateLimiter.addRule({apiKey: String, method: "getUserId"}, 5, 5000);
|
||||||
console.log(`Rate limit hit by API key ${apiKey}`);
|
rateLimiter.addRule({apiKey: String, method: "addSpellsToCharacter"}, 5, 5000);
|
||||||
return true;
|
rateLimiter.addRule({apiKey: String, method: "createCharacter"}, 5, 5000);
|
||||||
} else {
|
rateLimiter.addRule({apiKey: String, method: "transferCharacterOwnership"}, 5, 5000);
|
||||||
return false;
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,9 +51,16 @@
|
|||||||
<iron-icon icon="bug-report" item-icon></iron-icon>
|
<iron-icon icon="bug-report" item-icon></iron-icon>
|
||||||
Send Feedback
|
Send Feedback
|
||||||
</paper-icon-item>
|
</paper-icon-item>
|
||||||
<a class="patreon" href="https://www.patreon.com/dicecloud" target="_blank" tabindex="-1">
|
<a class="patreon" href="{{patreonLink}}" target="_blank" tabindex="-1">
|
||||||
<paper-icon-item>
|
<paper-icon-item>
|
||||||
<iron-icon icon="dicecloud:patreon" item-icon></iron-icon>
|
<iron-icon id="patreon-link-icon" icon="dicecloud:patreon" item-icon></iron-icon>
|
||||||
|
{{#if showPatreonBadge}}
|
||||||
|
<paper-badge
|
||||||
|
icon="av:new-releases"
|
||||||
|
for="patreon-link-icon"
|
||||||
|
label="New post">
|
||||||
|
</paper-badge>
|
||||||
|
{{/if}}
|
||||||
Patreon
|
Patreon
|
||||||
</paper-icon-item>
|
</paper-icon-item>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ Template.appDrawer.helpers({
|
|||||||
var user = Meteor.user();
|
var user = Meteor.user();
|
||||||
return user.profile && user.profile.username || user.username || "My Account";
|
return user.profile && user.profile.username || user.username || "My Account";
|
||||||
},
|
},
|
||||||
|
showPatreonBadge: function(){
|
||||||
|
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
|
||||||
|
let user = Meteor.user();
|
||||||
|
if (!post || !user) return false;
|
||||||
|
return post.link !== user.lastPatreonPostClicked;
|
||||||
|
},
|
||||||
|
patreonLink: function(){
|
||||||
|
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
|
||||||
|
return (post && post.link) || 'https://www.patreon.com/dicecloud';
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let drawerLayout;
|
let drawerLayout;
|
||||||
@@ -37,6 +47,9 @@ Template.appDrawer.events({
|
|||||||
closeDrawer(instance);
|
closeDrawer(instance);
|
||||||
},
|
},
|
||||||
"click .patreon": function(event, instance){
|
"click .patreon": function(event, instance){
|
||||||
|
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
|
||||||
|
let link = (post && post.link) || 'https://www.patreon.com/dicecloud';
|
||||||
|
Meteor.call('clickPatreonPost', link);
|
||||||
ga("send", "event", "externalLink", "patreon");
|
ga("send", "event", "externalLink", "patreon");
|
||||||
},
|
},
|
||||||
"click .github": function(event, instance){
|
"click .github": function(event, instance){
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
"polyfill": "/components/webcomponentsjs/webcomponents.min.js",
|
"polyfill": "/components/webcomponentsjs/webcomponents.min.js",
|
||||||
"useShadowDom": true,
|
"useShadowDom": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"/components/app-layout/app-layout.html",
|
"/components/app-layout/app-layout.html",
|
||||||
"/components/app-layout/app-layout.html",
|
"/components/app-layout/app-layout.html",
|
||||||
"/components/app-layout/app-scroll-effects/effects/waterfall.html",
|
"/components/app-layout/app-scroll-effects/effects/waterfall.html",
|
||||||
"/components/app-layout/app-scroll-effects/effects/parallax-background.html",
|
"/components/app-layout/app-scroll-effects/effects/parallax-background.html",
|
||||||
"/components/app-layout/app-scroll-effects/effects/resize-title.html",
|
"/components/app-layout/app-scroll-effects/effects/resize-title.html",
|
||||||
|
|
||||||
"/components/iron-collapse/iron-collapse.html",
|
"/components/iron-collapse/iron-collapse.html",
|
||||||
"/components/iron-icon/iron-icon.html",
|
"/components/iron-icon/iron-icon.html",
|
||||||
"/components/iron-icons/av-icons.html",
|
"/components/iron-icons/av-icons.html",
|
||||||
"/components/iron-icons/editor-icons.html",
|
"/components/iron-icons/editor-icons.html",
|
||||||
@@ -21,7 +21,8 @@
|
|||||||
|
|
||||||
"/components/neon-animation/neon-animation.html",
|
"/components/neon-animation/neon-animation.html",
|
||||||
|
|
||||||
"/components/paper-button/paper-button.html",
|
"/components/paper-button/paper-button.html",
|
||||||
|
"/components/paper-badge/paper-badge.html",
|
||||||
"/components/paper-swatch-picker/paper-swatch-picker.html",
|
"/components/paper-swatch-picker/paper-swatch-picker.html",
|
||||||
"/components/paper-dialog/paper-dialog.html",
|
"/components/paper-dialog/paper-dialog.html",
|
||||||
"/components/paper-dropdown-menu/paper-dropdown-menu.html",
|
"/components/paper-dropdown-menu/paper-dropdown-menu.html",
|
||||||
|
|||||||
471
app/lib/functions/api.js
Normal file
471
app/lib/functions/api.js
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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){
|
canEditCharacter = function(charId, userId){
|
||||||
userId = userId || Meteor.userId();
|
userId = userId || Meteor.userId();
|
||||||
var char = Characters.findOne(charId, {fields: {owner: 1, writers: 1}});
|
var char = Characters.findOne(charId, {fields: {owner: 1, writers: 1}});
|
||||||
@@ -9,7 +16,7 @@ canViewCharacter = function(char, userId){
|
|||||||
userId = userId || Meteor.userId();
|
userId = userId || Meteor.userId();
|
||||||
if (typeof char !== 'object'){
|
if (typeof char !== 'object'){
|
||||||
char = Characters.findOne(
|
char = Characters.findOne(
|
||||||
charId,
|
char,
|
||||||
{fields: {owner: 1, writers: 1, readers: 1, "settings.viewPermission": 1}}
|
{fields: {owner: 1, writers: 1, readers: 1, "settings.viewPermission": 1}}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/package-lock.json
generated
6
app/package-lock.json
generated
@@ -216,9 +216,9 @@
|
|||||||
"integrity": "sha512-HfVRxhYG7C8Jl9FqtrlElMR2z/8YiLQVDKf67MLY25Ic+ILx3ecmklfT1v3u+7P5/4vEFjuxaAFXhr2/Afwk5g=="
|
"integrity": "sha512-HfVRxhYG7C8Jl9FqtrlElMR2z/8YiLQVDKf67MLY25Ic+ILx3ecmklfT1v3u+7P5/4vEFjuxaAFXhr2/Afwk5g=="
|
||||||
},
|
},
|
||||||
"file-saver": {
|
"file-saver": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.1.tgz",
|
||||||
"integrity": "sha512-cYM1ic5DAkg25pHKgi5f10ziAM7RJU37gaH1XQlyNDrtUnzhC/dfoV9zf2OmF0RMKi42jG5B0JWBnPQqyj/G6g=="
|
"integrity": "sha512-dCB3K7/BvAcUmtmh1DzFdv0eXSVJ9IAFt1mw3XZfAexodNRoE29l3xB2EX4wH2q8m/UTzwzEPq/ArYk98kUkBQ=="
|
||||||
},
|
},
|
||||||
"find-up": {
|
"find-up": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"bower": "^1.7.9",
|
"bower": "^1.7.9",
|
||||||
"core-js": "^2.5.7",
|
"core-js": "^2.5.7",
|
||||||
"fibers": "^2.0.2",
|
"fibers": "^2.0.2",
|
||||||
"file-saver": "^2.0.0",
|
"file-saver": "^2.0.1",
|
||||||
"meteor-node-stubs": "^0.3.3",
|
"meteor-node-stubs": "^0.3.3",
|
||||||
"qrcode": "^1.3.0",
|
"qrcode": "^1.3.0",
|
||||||
"source-map-support": "^0.5.9",
|
"source-map-support": "^0.5.9",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
Meteor.publish("user", function(){
|
Meteor.publish("user", function(){
|
||||||
return Meteor.users.find(this.userId, {fields: {
|
return [
|
||||||
roles: 1,
|
Meteor.users.find(this.userId, {fields: {
|
||||||
username: 1,
|
roles: 1,
|
||||||
profile: 1,
|
username: 1,
|
||||||
apiKey: 1,
|
profile: 1,
|
||||||
librarySubscriptions: 1,
|
apiKey: 1,
|
||||||
}});
|
librarySubscriptions: 1,
|
||||||
|
lastPatreonPostClicked: 1,
|
||||||
|
}}),
|
||||||
|
PatreonPosts.find({},{sort: {dateAdded: -1}, limit: 1})
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user