mirror of
https://github.com/PretendoNetwork/website.git
synced 2026-08-23 09:07:40 -05:00
Merge pull request #11 from PretendoNetwork/dev
added basically everything for the backend except pnid stuff
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -59,4 +59,4 @@ typings/
|
||||
|
||||
# keep config and blog posts out of this
|
||||
config.json
|
||||
posts/
|
||||
static-text.json
|
||||
@@ -2,18 +2,14 @@
|
||||
"http": {
|
||||
"port": 8080
|
||||
},
|
||||
"blog": {
|
||||
"authors": [
|
||||
{
|
||||
"name": "redducks",
|
||||
"description": "passionate and violence loving programmer",
|
||||
"image": "https://img00.deviantart.net/a0f8/i/2012/356/3/3/generic_anime_girl__1_by_light1523-d5ou547.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"database": {
|
||||
"url": "mongodb://localhost:27017/pretendo_website"
|
||||
},
|
||||
"contactWebhook": {
|
||||
"port": 443,
|
||||
"host": "host.com",
|
||||
"path": "/webhookurl"
|
||||
},
|
||||
"secrets": {
|
||||
"session": "session secret here"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
/*
|
||||
|
||||
common.js -
|
||||
common page functionality.
|
||||
api.js -
|
||||
common api returns
|
||||
|
||||
*/
|
||||
|
||||
// shows 404 template.
|
||||
function sendDefault404(res) {
|
||||
res.status(404).send('404');
|
||||
}
|
||||
|
||||
// use for any api return. it has basic layout used for every return.
|
||||
function sendApiReturn(res, data, errors) {
|
||||
function sendReturn(res, data, errors) {
|
||||
res.status(200).json(
|
||||
// combine 2 objects
|
||||
Object.assign({
|
||||
@@ -64,8 +59,7 @@ function sendApiError(res, code, errors) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendDefault404,
|
||||
sendApiReturn,
|
||||
sendReturn,
|
||||
sendApi404,
|
||||
sendApiGenericError,
|
||||
sendApiError,
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
|
||||
blog-helper.js -
|
||||
blog post helper functionality
|
||||
|
||||
*/
|
||||
|
||||
// imports
|
||||
const fs = require('fs');
|
||||
const authors = require('../config.json').blog.authors;
|
||||
const showdown = require('showdown');
|
||||
const converter = new showdown.Converter({metadata: true});
|
||||
|
||||
|
||||
function getAuthorByID(authorId) {
|
||||
return authors[authorId];
|
||||
}
|
||||
|
||||
function getBlogPostAsMarkdown(postId) {
|
||||
try {
|
||||
return fs.readFileSync(`posts/${postId}.md`);
|
||||
} catch(exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBlogPostAsHtml(postId) {
|
||||
const markdown = getBlogPostAsMarkdown(postId);
|
||||
if (!markdown) return;
|
||||
return converter.makeHtml(markdown);
|
||||
}
|
||||
|
||||
function getBlogPostExpressReady(postId) {
|
||||
const markdown = getBlogPostAsMarkdown(postId);
|
||||
if (!markdown) return;
|
||||
const html = converter.makeHtml(markdown);
|
||||
const metadata = converter.getMetadata();
|
||||
const hbsObject = {
|
||||
content: html,
|
||||
date: metadata.releaseDate,
|
||||
category: metadata.category,
|
||||
author: getAuthorByID(metadata.authorId)
|
||||
};
|
||||
|
||||
return hbsObject;
|
||||
}
|
||||
|
||||
function writeMarkdownToFile(text) {
|
||||
const length = fs.readdirSync('posts').length;
|
||||
fs.writeFileSync(`posts/${length}.md`, text);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBlogPostAsHtml,
|
||||
getBlogPostAsMarkdown,
|
||||
getBlogPostExpressReady,
|
||||
getAuthorByID,
|
||||
writeMarkdownToFile
|
||||
};
|
||||
15
helpers/util.js
Normal file
15
helpers/util.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
|
||||
util.js -
|
||||
small commonly used utilities
|
||||
|
||||
*/
|
||||
|
||||
// shows 404 template. takes express response object
|
||||
function sendDefault404(res) {
|
||||
res.status(404).send('404');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendDefault404
|
||||
};
|
||||
@@ -6,23 +6,23 @@ Middleware file for authentication checking
|
||||
*/
|
||||
|
||||
// imports
|
||||
const common = require('../helpers/common');
|
||||
const apiHelper = require('../helpers/api');
|
||||
|
||||
// middleware to use if admin authentication is required
|
||||
function adminAuthenticationRequired(req, res, next) {
|
||||
function adminAuthNeeded(req, res, next) {
|
||||
if (req.isAuthenticated() && req.user.role && req.user.role === 'admin') {
|
||||
return next();
|
||||
} else {
|
||||
common.sendApiAuthError(res);
|
||||
apiHelper.sendApiAuthError(res);
|
||||
}
|
||||
}
|
||||
|
||||
// middleware to use if authentication
|
||||
function authenticationOptional(req, res, next) {
|
||||
// middleware to use if authentication is optional
|
||||
function authOptional(req, res, next) {
|
||||
return next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
adminAuthenticationRequired,
|
||||
authenticationOptional
|
||||
adminAuthNeeded,
|
||||
authOptional
|
||||
};
|
||||
@@ -20,6 +20,7 @@ const adminUserSchema = new mongoose.Schema({
|
||||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
// non hashed, gets hashed at save
|
||||
password: {
|
||||
type: String,
|
||||
required: [true, 'Password is required.'],
|
||||
|
||||
104
models/blog-post.js
Normal file
104
models/blog-post.js
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
|
||||
blog-post.js -
|
||||
file containing the model file for a blog post
|
||||
|
||||
*/
|
||||
|
||||
// imports
|
||||
const mongoose = require('mongoose');
|
||||
const postAuthor = require('./post-author').postAuthorModel;
|
||||
const showdown = require('showdown');
|
||||
const moment = require('moment');
|
||||
const converter = new showdown.Converter();
|
||||
converter.setFlavor('github');
|
||||
|
||||
// admin user database layout
|
||||
const blogPostSchema = new mongoose.Schema({
|
||||
// html in content
|
||||
content: {
|
||||
type: String,
|
||||
required: [true, 'Content is required.'],
|
||||
trim: true
|
||||
},
|
||||
// title of blog post
|
||||
name: {
|
||||
type: String,
|
||||
required: [true, 'Name is required']
|
||||
},
|
||||
// short description of blog post
|
||||
short: {
|
||||
type: String,
|
||||
required: [true, 'Short version is required']
|
||||
},
|
||||
meta: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: [true, 'Author is required'],
|
||||
trim: true
|
||||
},
|
||||
author: {
|
||||
type: String,
|
||||
required: [true, 'Author is required'],
|
||||
trim: true
|
||||
},
|
||||
date: {
|
||||
type: Date,
|
||||
default: () => {
|
||||
return new Date(moment(new Date(), 'YYYY-MM-DD'));
|
||||
}
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
required: [true, 'category is required'],
|
||||
trim: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
blogPostSchema.methods.postTemplate = function(callback) {
|
||||
const self = this;
|
||||
postAuthor.findById(this.meta.author, function (err, author) {
|
||||
callback(err, {
|
||||
content: self.content,
|
||||
title: self.name,
|
||||
date: self.meta.date,
|
||||
category: self.meta.category,
|
||||
author: author.authorTemplate()
|
||||
});
|
||||
});
|
||||
};
|
||||
blogPostSchema.methods.postShortTemplate = function() {
|
||||
return {
|
||||
content: this.short,
|
||||
title: this.name,
|
||||
url: moment(this.meta.date, 'YYYY-MM-DD') + '/' + this.meta.slug
|
||||
};
|
||||
};
|
||||
|
||||
blogPostSchema.statics.markdownToHtml = function(markdown) {
|
||||
return converter.makeHtml(markdown);
|
||||
};
|
||||
blogPostSchema.statics.getPost = function(date, slug, callback) {
|
||||
return blogPostModel.findOne({
|
||||
'meta.date': date,
|
||||
'meta.slug': slug
|
||||
}, callback);
|
||||
};
|
||||
// not tested
|
||||
blogPostSchema.statics.latestPostsShortTemlate = function(amount, callback) {
|
||||
blogPostModel.find({}).sort({'meta.date': 'desc'}).exec(function(err, posts) {
|
||||
if (err) return callback(err);
|
||||
let out = [];
|
||||
for (let i = 0, l = posts.length; i < ( amount+1 < l ? amount+1 : l); i++)
|
||||
out += posts[i].postShortTemplate();
|
||||
callback(err, out);
|
||||
});
|
||||
};
|
||||
|
||||
const blogPostModel = mongoose.model('blogPost', blogPostSchema);
|
||||
|
||||
module.exports = {
|
||||
blogPostModel,
|
||||
blogPostSchema
|
||||
};
|
||||
43
models/post-author.js
Normal file
43
models/post-author.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
|
||||
post-author.js -
|
||||
file containing the model for authors
|
||||
|
||||
*/
|
||||
|
||||
// imports
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
// post author database layout
|
||||
const postAuthorSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: [true, 'Name is required'],
|
||||
trim: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: [true, 'Description is required'],
|
||||
trim: true
|
||||
},
|
||||
// profile picture image link
|
||||
image: {
|
||||
type: String,
|
||||
trim: true
|
||||
}
|
||||
});
|
||||
|
||||
postAuthorSchema.methods.authorTemplate = function() {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
profilePicture: this.image
|
||||
};
|
||||
};
|
||||
|
||||
const postAuthorModel = mongoose.model('postAuthor', postAuthorSchema);
|
||||
|
||||
module.exports = {
|
||||
postAuthorModel,
|
||||
postAuthorSchema
|
||||
};
|
||||
36
models/progress-list.js
Normal file
36
models/progress-list.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
|
||||
progress-list.js -
|
||||
file containing the model for progress list
|
||||
|
||||
*/
|
||||
|
||||
// imports
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
// progress text schema
|
||||
const progressListSchema = new mongoose.Schema({
|
||||
state: {
|
||||
type: Number, /* 0 - nothing, 1 - no support, 2 - partially working, 3 - works */
|
||||
default: 0
|
||||
},
|
||||
isGame: {
|
||||
type: Boolean, /* true - is game, false - is backend service */
|
||||
required: [true, 'isGame is required']
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: [true, 'Title is required']
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: [true, 'Description is required']
|
||||
}
|
||||
});
|
||||
|
||||
const progressListModel = mongoose.model('progress', progressListSchema);
|
||||
|
||||
module.exports = {
|
||||
progressListModel,
|
||||
progressListSchema
|
||||
};
|
||||
5
package-lock.json
generated
5
package-lock.json
generated
@@ -1746,6 +1746,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.22.2",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz",
|
||||
"integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y="
|
||||
},
|
||||
"mongodb": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.1.6.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"express": "^4.16.2",
|
||||
"express-handlebars": "^3.0.0",
|
||||
"express-session": "^1.15.6",
|
||||
"moment": "^2.22.2",
|
||||
"mongoose": "^5.3.2",
|
||||
"mongoose-unique-validator": "^2.0.2",
|
||||
"passport": "^0.4.0",
|
||||
|
||||
@@ -42,7 +42,7 @@ module.exports = (app) => {
|
||||
});
|
||||
}
|
||||
));
|
||||
//Configuring app to have sessions
|
||||
//Configuring app to have sessions, dont change since it would break everything
|
||||
passport.serializeUser(function(user, done) {
|
||||
done(null, user.id);
|
||||
});
|
||||
|
||||
377
routes/admin.js
377
routes/admin.js
@@ -1,18 +1,24 @@
|
||||
/*
|
||||
|
||||
admin.js -
|
||||
file for handling admin panel routes
|
||||
file for handling admin api.
|
||||
|
||||
*/
|
||||
|
||||
// imports
|
||||
const router = require('express').Router();
|
||||
const passport = require('passport');
|
||||
const common = require('../helpers/common');
|
||||
const moment = require('moment');
|
||||
const apiHelper = require('../helpers/api');
|
||||
const adminUserMiddleware = require('../middleware/admin-authentication');
|
||||
const adminUser = require('../models/admin-user');
|
||||
|
||||
// display admin panel
|
||||
// database models
|
||||
const adminUser = require('../models/admin-user');
|
||||
const blogPost = require('../models/blog-post');
|
||||
const postAuthor = require('../models/post-author');
|
||||
const progressList = require('../models/progress-list');
|
||||
|
||||
// renders admin.hbs
|
||||
router.get('/admin', (req, res) => {
|
||||
res.render('admin');
|
||||
});
|
||||
@@ -23,20 +29,21 @@ router.get('/admin', (req, res) => {
|
||||
* signs admin user in
|
||||
*
|
||||
* post {
|
||||
* username - username of admin account
|
||||
* password - password of admin account
|
||||
* username
|
||||
* password
|
||||
* }
|
||||
* return {
|
||||
* code: http code
|
||||
* success: boolean - true if login succesfull
|
||||
* username: undefined | string - username if login was successfull
|
||||
* success: boolean
|
||||
* username: undefined | string - only if login was successfull
|
||||
* role: undefined | string - role of user if login was successfull
|
||||
* errors: Strings[messages] - not yet :(
|
||||
* }
|
||||
*/
|
||||
// TODO make login somehow display errors in correct format.
|
||||
// middleware does the authentication work. this just returns a success
|
||||
router.post('/admin/api/v1/login', passport.authenticate('adminUserStrategy'), function (req, res) {
|
||||
common.sendApiReturn(res, {
|
||||
apiHelper.sendReturn(res, {
|
||||
username: req.user.username,
|
||||
role: req.user.role ? req.user.role : undefined
|
||||
});
|
||||
@@ -60,10 +67,10 @@ router.post('/admin/api/v1/login', passport.authenticate('adminUserStrategy'), f
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.post('/admin/api/v1/register', adminUserMiddleware.adminAuthenticationRequired, (req, res) => {
|
||||
router.post('/admin/api/v1/register', adminUserMiddleware.adminAuthNeeded, (req, res) => {
|
||||
if (!req.body) {
|
||||
// no post body
|
||||
common.sendApiGenericError(res);
|
||||
apiHelper.sendApiGenericError(res);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,20 +80,80 @@ router.post('/admin/api/v1/register', adminUserMiddleware.adminAuthenticationReq
|
||||
password
|
||||
});
|
||||
|
||||
// saving to database
|
||||
newUser.save().then(() => {
|
||||
// successfull
|
||||
common.sendApiReturn(res, {
|
||||
apiHelper.sendReturn(res, {
|
||||
username: req.user.username,
|
||||
role: req.user.role ? req.user.role : undefined
|
||||
});
|
||||
return;
|
||||
}).catch((rejection) => {
|
||||
// TODO format exception so it doesnt have a huge list of errors
|
||||
common.sendApiError(res, 500, [rejection]);
|
||||
apiHelper.sendApiError(res, 500, [rejection]);
|
||||
return;
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /admin/api/v1/removeadmin
|
||||
* - requires admin auth
|
||||
*
|
||||
* registers a new admin user
|
||||
*
|
||||
* post {
|
||||
* id - id of the admin user
|
||||
* }
|
||||
* return {
|
||||
* code: httpcode
|
||||
* success: boolean - true if delete was successull
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.post('/admin/api/v1/removeadmin', adminUserMiddleware.adminAuthNeeded, (req, res) => {
|
||||
if (!req.body) {
|
||||
// no post body
|
||||
apiHelper.sendApiGenericError(res);
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.body;
|
||||
adminUser.adminUserModel.findByIdAndDelete(id, (err) => {
|
||||
if (err) return apiHelper.sendApiError(res, 500, [err]);
|
||||
// successfull
|
||||
apiHelper.sendReturn(res, {});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /admin/api/v1/listadmins
|
||||
* - requires admin auth
|
||||
*
|
||||
* gets list of admins
|
||||
*
|
||||
* return {
|
||||
* code: httpcode
|
||||
* success: boolean - true if delete was successull
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.get('/admin/api/v1/listadmins', adminUserMiddleware.adminAuthNeeded, (req, res) => {
|
||||
adminUser.adminUserModel.find({}, (err, admins) => {
|
||||
// TODO format exception so it doesnt have a huge list of errors
|
||||
if (err) return apiHelper.sendApiError(res, 500, [err]);
|
||||
|
||||
// formats admin list and removes password hash
|
||||
const output = [];
|
||||
for (let i = 0, l = admins.length; i < l; i++) {
|
||||
admins[i].password = undefined;
|
||||
output.push(admins[i]);
|
||||
}
|
||||
|
||||
apiHelper.sendReturn(res, {
|
||||
admins: output
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /admin/api/v1/check
|
||||
*
|
||||
@@ -100,8 +167,8 @@ router.post('/admin/api/v1/register', adminUserMiddleware.adminAuthenticationReq
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.get('/admin/api/v1/check', adminUserMiddleware.authenticationOptional, (req, res) => {
|
||||
common.sendApiReturn(res, {
|
||||
router.get('/admin/api/v1/check', adminUserMiddleware.authOptional, (req, res) => {
|
||||
apiHelper.sendReturn(res, {
|
||||
isAuthed: req.user ? true : false,
|
||||
role: req.user ? (req.user.role ? req.user.role : undefined) : undefined
|
||||
});
|
||||
@@ -118,9 +185,283 @@ router.get('/admin/api/v1/check', adminUserMiddleware.authenticationOptional, (r
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.get('/admin/api/v1/logout', adminUserMiddleware.adminAuthenticationRequired, (req, res) => {
|
||||
router.get('/admin/api/v1/logout', adminUserMiddleware.adminAuthNeeded, (req, res) => {
|
||||
req.logout();
|
||||
common.sendApiReturn(res, {});
|
||||
apiHelper.sendReturn(res, {});
|
||||
});
|
||||
|
||||
/*
|
||||
* /admin/api/v1/newpost
|
||||
*
|
||||
* posts a new blog post
|
||||
*
|
||||
* post {
|
||||
* content - content of the blog post in markdown
|
||||
* title - title of the blog post
|
||||
* author - id of the author
|
||||
* short - short description of content in plain text
|
||||
* category - category name of the blog post
|
||||
*
|
||||
* }
|
||||
* return {
|
||||
* code: http code
|
||||
* success: boolean - true if login succesfull
|
||||
* url: string | undefined - url of the blog post if successfull
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.post('/admin/api/v1/newpost', adminUserMiddleware.adminAuthNeeded, function (req, res) {
|
||||
|
||||
if (!req.body) return apiHelper.sendApiGenericError(res);
|
||||
|
||||
const { content, title, author, category, short } = req.body;
|
||||
const newBlogPost = new blogPost.blogPostModel({
|
||||
content: blogPost.blogPostModel.markdownToHtml(content),
|
||||
name: title,
|
||||
short,
|
||||
meta: {
|
||||
author,
|
||||
category,
|
||||
slug: title // convert title to slug
|
||||
.trim()
|
||||
.replace(/\s/g, '-')
|
||||
.replace(/[^A-z0-9-]/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
});
|
||||
|
||||
// saving post to database
|
||||
newBlogPost.save().then((post) => {
|
||||
apiHelper.sendReturn(res, {
|
||||
url: moment(post.meta.date, 'YYYY-MM-DD') + '/' + post.meta.slug
|
||||
});
|
||||
}).catch((rejection) => {
|
||||
// TODO format exception so it doesnt have a huge list of errors
|
||||
apiHelper.sendApiError(res, 500, [rejection]);
|
||||
return;
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /admin/api/v1/editpost
|
||||
*
|
||||
* edits a blog post
|
||||
*
|
||||
* post {
|
||||
* id - id of the blog post to be edited
|
||||
* content - content of the blog post IN HTML
|
||||
* title - title of the blog post
|
||||
* author - id of the author
|
||||
* short - short description of content in plain text
|
||||
* category - category name of the blog post
|
||||
* }
|
||||
* return {
|
||||
* code: http code
|
||||
* success: boolean - true if login succesfull
|
||||
* url: string | undefined - url of the blog post if successfull
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.post('/admin/api/v1/editpost', adminUserMiddleware.adminAuthNeeded, function (req, res) {
|
||||
|
||||
if (!req.body) return apiHelper.sendApiGenericError(res);
|
||||
|
||||
const { id, content, title, author, category, short } = req.body;
|
||||
blogPost.blogPostModel.findByIdAndUpdate(id, {
|
||||
'content': content,
|
||||
'name': title,
|
||||
'short': short,
|
||||
'meta.author': author,
|
||||
'meta.category': category
|
||||
}, (err, post) => {
|
||||
if (err) return apiHelper.sendApiError(res, 500, [err]);
|
||||
|
||||
apiHelper.sendReturn(res, {
|
||||
url: moment(post.meta.date, 'YYYY-MM-DD') + '/' + post.meta.slug
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /admin/api/v1/newauthor
|
||||
*
|
||||
* creates new author
|
||||
*
|
||||
* post {
|
||||
* name - author name
|
||||
* description - author description
|
||||
* image - image url for profile picture
|
||||
* }
|
||||
* return {
|
||||
* code: http code
|
||||
* success: boolean - true if author succesfull
|
||||
* id: string | undefined - id of the new author if successfull
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.post('/admin/api/v1/newauthor', adminUserMiddleware.adminAuthNeeded, function (req, res) {
|
||||
|
||||
if (!req.body) return apiHelper.sendApiGenericError(res);
|
||||
|
||||
const { name, description, image } = req.body;
|
||||
const newAuthor = new postAuthor.postAuthorModel({
|
||||
name,
|
||||
description,
|
||||
image
|
||||
});
|
||||
|
||||
// saving author to database
|
||||
newAuthor.save().then((author) => {
|
||||
apiHelper.sendReturn(res, {
|
||||
id: author.id
|
||||
});
|
||||
}).catch((rejection) => {
|
||||
// TODO format exception so it doesnt have a huge list of errors
|
||||
apiHelper.sendApiError(res, 500, [rejection]);
|
||||
return;
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /admin/api/v1/editauthor
|
||||
*
|
||||
* edit an existing author
|
||||
*
|
||||
* post {
|
||||
* id - id of author to edit
|
||||
* name - author name
|
||||
* description - author description
|
||||
* image - image url for profile picture
|
||||
* }
|
||||
* return {
|
||||
* code: http code
|
||||
* success: boolean - true if author succesfull
|
||||
* id: String - id of the edited author
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.post('/admin/api/v1/editauthor', adminUserMiddleware.adminAuthNeeded, function (req, res) {
|
||||
|
||||
if (!req.body) return apiHelper.sendApiGenericError(res);
|
||||
|
||||
const { id, name, description, image } = req.body;
|
||||
|
||||
// updating author in database
|
||||
postAuthor.postAuthorModel.findByIdAndUpdate(id, {
|
||||
name,
|
||||
description,
|
||||
image
|
||||
}, (err, author) => {
|
||||
// TODO format exception so it doesnt have a huge list of errors
|
||||
if (err) return apiHelper.sendApiError(res, 500, [err]);
|
||||
apiHelper.sendReturn(res, {
|
||||
id: author.id
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /admin/api/v1/newprogress
|
||||
*
|
||||
* creates a new progress entry
|
||||
*
|
||||
* post {
|
||||
* title - progress entry name
|
||||
* description - progress entry description
|
||||
* state - 0: backend service entry, 1: no support, 2: partial support, 3: fullly working
|
||||
* }
|
||||
* return {
|
||||
* code: http code
|
||||
* success: boolean - true if progress succesfull
|
||||
* id: String | undefined - sends if successfull
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.post('/admin/api/v1/newprogress', adminUserMiddleware.adminAuthNeeded, function (req, res) {
|
||||
|
||||
if (!req.body) return apiHelper.sendApiGenericError(res);
|
||||
|
||||
// parses state and isGame to be valid
|
||||
const { title, description } = req.body;
|
||||
let { state } = req.body;
|
||||
let isGame = false;
|
||||
if (state != '1' && state != '2' && state != '3') {
|
||||
state = undefined;
|
||||
} else {
|
||||
state = parseInt(state);
|
||||
isGame = true;
|
||||
}
|
||||
|
||||
const newProgress = new progressList.progressListModel({
|
||||
title,
|
||||
description,
|
||||
isGame,
|
||||
state
|
||||
});
|
||||
|
||||
// saving progress to database
|
||||
newProgress.save().then((progress) => {
|
||||
apiHelper.sendReturn(res, {
|
||||
id: progress.id
|
||||
});
|
||||
}).catch((rejection) => {
|
||||
// TODO format exception so it doesnt have a huge list of errors
|
||||
apiHelper.sendApiError(res, 500, [rejection]);
|
||||
return;
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /admin/api/v1/editprogress
|
||||
*
|
||||
* edit an existing progress entry
|
||||
*
|
||||
* post {
|
||||
* title - progress entry name
|
||||
* description - progress entry description
|
||||
* state - 0: backend service entry, 1: no support, 2: partial support, 3: fullly working
|
||||
* id - id of entry you want to edit
|
||||
* }
|
||||
* return {
|
||||
* code: http code
|
||||
* success: boolean - true if progress succesfull
|
||||
* id: String - id of the edited progress entry
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.post('/admin/api/v1/editprogress', adminUserMiddleware.adminAuthNeeded, function (req, res) {
|
||||
|
||||
if (!req.body) return apiHelper.sendApiGenericError(res);
|
||||
|
||||
// parsing state and isGame to be valid
|
||||
const { title, description, id } = req.body;
|
||||
let { state } = req.body;
|
||||
let isGame = false;
|
||||
if (state != '1' && state != '2' && state != '3') {
|
||||
state = undefined;
|
||||
} else {
|
||||
state = parseInt(state);
|
||||
isGame = true;
|
||||
}
|
||||
|
||||
// updating progress in database
|
||||
progressList.progressListModel.findByIdAndUpdate(id, {
|
||||
title,
|
||||
description,
|
||||
state,
|
||||
isGame
|
||||
}, (err, progress) => {
|
||||
// TODO format exception so it doesnt have a huge list of errors
|
||||
if (err) return apiHelper.sendApiError(res, 500, [err]);
|
||||
apiHelper.sendReturn(res, {
|
||||
id: progress.id
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// api 404
|
||||
router.use('/admin/api', (req, res) => {
|
||||
apiHelper.sendApi404(res);
|
||||
});
|
||||
|
||||
// export the router
|
||||
|
||||
102
routes/blog.js
102
routes/blog.js
@@ -7,25 +7,99 @@ file for handling routes regarding blog posts.
|
||||
|
||||
// imports
|
||||
const router = require('express').Router();
|
||||
const blogHelper = require('../helpers/blog-helper.js');
|
||||
const apiHelper = require('../helpers/api');
|
||||
const utilHelper = require('../helpers/util');
|
||||
const blogPostModel = require('../models/blog-post').blogPostModel;
|
||||
const postAuthorModel = require('../models/post-author').postAuthorModel;
|
||||
|
||||
// display blog post
|
||||
router.get('/news/:id', async (req, res) => {
|
||||
if (isNaN(req.params.id)) {
|
||||
res.statusCode = 404;
|
||||
res.render('404');
|
||||
return;
|
||||
// display single blog post
|
||||
router.get('/news/:date/:title', (req, res) => {
|
||||
// date format YYYY-MM-DD
|
||||
if (/[0-9]{4}-[0-9]{2}-[0-9]{2}/.test(req.params.date) && /([a-z]|[0-9]|-)+/.test(req.params.title.toLowerCase())) {
|
||||
// params are correct format
|
||||
blogPostModel.getPost(new Date(req.params.date), req.params.title.toLowerCase(), (err, post) => {
|
||||
// error exists or no post exists with the date and name
|
||||
if (err || !post) {
|
||||
console.log('error: ' + err + ' and post: ' + post);
|
||||
return utilHelper.send404(res);
|
||||
}
|
||||
|
||||
// render blogpost
|
||||
post.postTemplate((err, postTemplate) => {
|
||||
if (err) return utilHelper.send404(res);
|
||||
res.render('post', {
|
||||
post: postTemplate
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// params are incorrect
|
||||
utilHelper.send404(res);
|
||||
}
|
||||
});
|
||||
|
||||
const hbsObject = blogHelper.getBlogPostExpressReady(req.params.id);
|
||||
// display latest blogposts
|
||||
router.get('/news', (req, res) => {
|
||||
// sort blogposts on date descending
|
||||
blogPostModel.find({}).sort({'meta.date': 'desc'}).exec(function(err, posts) {
|
||||
if (err || !posts) {
|
||||
return utilHelper.send404(res);
|
||||
}
|
||||
|
||||
if (!hbsObject) {
|
||||
res.statusCode = 404;
|
||||
res.render('404');
|
||||
return;
|
||||
}
|
||||
// makes posts template ready
|
||||
const postCollection = [];
|
||||
for (let i = 0, l = posts.length; i < l; i++) {
|
||||
postCollection.push(posts[i].postShortTemplate());
|
||||
}
|
||||
|
||||
res.render('post', hbsObject);
|
||||
res.render('post-collection', {
|
||||
posts: postCollection
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /api/v1/listauthors
|
||||
*
|
||||
* gets a list of all authors
|
||||
*
|
||||
* return {
|
||||
* code: http code
|
||||
* success: boolean - true if author succesfull
|
||||
* authorList: Objects[{_id, name, description, image}] - list of authors with information
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.get('/api/v1/listauthors', function (req, res) {
|
||||
postAuthorModel.find({}, (err, authors) => {
|
||||
// TODO format exception so it doesnt have a huge list of errors
|
||||
if (err) return apiHelper.sendApiError(res, 500, [err]);
|
||||
apiHelper.sendReturn(res, {
|
||||
authorList: authors
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /api/v1/listblog
|
||||
*
|
||||
* gets a list of all posts
|
||||
*
|
||||
* return {
|
||||
* code: http code
|
||||
* success: boolean - true if post succesfull
|
||||
* postList: Objects[{_id, content, meta}] - list of posts with information
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.get('/api/v1/listblog', function (req, res) {
|
||||
blogPostModel.find({}, (err, posts) => {
|
||||
// TODO format exception so it doesnt have a huge list of errors
|
||||
if (err) return apiHelper.sendApiError(res, 500, [err]);
|
||||
apiHelper.sendReturn(res, {
|
||||
postList: posts
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// export router
|
||||
|
||||
79
routes/contact.js
Normal file
79
routes/contact.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
|
||||
contact.js -
|
||||
file for handling routes regarding contact
|
||||
|
||||
*/
|
||||
|
||||
// imports
|
||||
const router = require('express').Router();
|
||||
const apiHelper = require('../helpers/api');
|
||||
const config = require('../config.json');
|
||||
const https = require('https');
|
||||
|
||||
// display contact page
|
||||
router.get('/contact', (req, res) => {
|
||||
res.render('contact');
|
||||
});
|
||||
|
||||
/*
|
||||
* /api/v1/sendmessage
|
||||
*
|
||||
* registers a new admin user
|
||||
*
|
||||
* post {
|
||||
* email - email of sender
|
||||
* subject - subject of message
|
||||
* message - actual message
|
||||
* }
|
||||
* return {
|
||||
* code: httpcode
|
||||
* success: boolean - true if sending was successull
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.post('/api/v1/sendmessage', function (req, res) {
|
||||
if (!req.body) return apiHelper.sendApiGenericError(res);
|
||||
|
||||
|
||||
const { email, subject, message } = req.body;
|
||||
if (email && subject && message && message.length < 2000) {
|
||||
// request body has everything
|
||||
const postData = JSON.stringify({
|
||||
content: 'email: ' + email + ' \n subject: ' + subject + ' \n\n' + message
|
||||
});
|
||||
|
||||
// request object
|
||||
const request = https.request({
|
||||
hostname: config.contactWebhook.host,
|
||||
port: config.contactWebhook.port,
|
||||
path: config.contactWebhook.path,
|
||||
method : 'POST',
|
||||
headers : {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Content-Length': postData.length
|
||||
}
|
||||
}, () => {
|
||||
// sends success
|
||||
apiHelper.sendReturn(res, {});
|
||||
});
|
||||
|
||||
// error handling
|
||||
request.on('error', (e) => {
|
||||
apiHelper.sendApiGenericError(res);
|
||||
console.log('request errored' + e);
|
||||
});
|
||||
|
||||
// write post data to request
|
||||
request.write(postData);
|
||||
request.end();
|
||||
} else {
|
||||
// TODO give more detailed response
|
||||
return apiHelper.sendApiGenericError(res);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// export router
|
||||
module.exports = router;
|
||||
@@ -14,10 +14,5 @@ router.get('/', (req, res) => {
|
||||
res.render('home');
|
||||
});
|
||||
|
||||
// display contact page
|
||||
router.get('/contact', (req, res) => {
|
||||
res.render('contact');
|
||||
});
|
||||
|
||||
// export the router
|
||||
module.exports = router;
|
||||
|
||||
56
routes/progress.js
Normal file
56
routes/progress.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
|
||||
progress.js -
|
||||
file for handling routes regarding progress
|
||||
|
||||
*/
|
||||
|
||||
// imports
|
||||
const router = require('express').Router();
|
||||
const apiHelper = require('../helpers/api');
|
||||
const utilHelper = require('../helpers/util');
|
||||
const staticText = require('../static-text.json');
|
||||
const progressListModel = require('../models/progress-list').progressListModel;
|
||||
|
||||
// display progress
|
||||
router.get('/progress', (req, res) => {
|
||||
|
||||
progressListModel.find({}, (err, progress) => {
|
||||
if (err) return utilHelper.send404(res);
|
||||
|
||||
// filtering games and backend
|
||||
const games = progress.filter(i => i.isGame);
|
||||
const backends = progress.filter(i => !i.isGame);
|
||||
|
||||
res.render('progress', {
|
||||
games,
|
||||
backends,
|
||||
summary: staticText.progressSummary
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* /api/v1/listprogress
|
||||
*
|
||||
* gets a list of progress
|
||||
*
|
||||
* return {
|
||||
* code: http code
|
||||
* success: boolean - true if progress succesfull
|
||||
* progressList: Objects[{_id, title, description, state}] - list of progress with information
|
||||
* errors: Strings[messages]
|
||||
* }
|
||||
*/
|
||||
router.get('/api/v1/listprogress', function (req, res) {
|
||||
progressListModel.find({}, (err, progress) => {
|
||||
// TODO format exception so it doesnt have a huge list of errors
|
||||
if (err) return apiHelper.sendApiError(res, 500, [err]);
|
||||
apiHelper.sendReturn(res, {
|
||||
progressList: progress
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// export router
|
||||
module.exports = router;
|
||||
19
server.js
19
server.js
@@ -13,7 +13,7 @@ const mongoStore = require('connect-mongo')(session);
|
||||
const mongoose = require('mongoose');
|
||||
const bodyParser = require('body-parser');
|
||||
const config = require('./config.json');
|
||||
const common = require('./helpers/common');
|
||||
const utilHelper = require('./helpers/util');
|
||||
const passportconfig = require('./passport.config.js');
|
||||
|
||||
// setup console colors
|
||||
@@ -45,6 +45,7 @@ app.use(session({
|
||||
collection: 'sessions'
|
||||
})
|
||||
}));
|
||||
// setups up passportjs authentication
|
||||
passportconfig(app);
|
||||
|
||||
// handlebars templating setup
|
||||
@@ -59,17 +60,29 @@ app.set('view engine', '.hbs');
|
||||
const locations = {
|
||||
home: require('./routes/home'),
|
||||
posts: require('./routes/blog'),
|
||||
admin: require('./routes/admin')
|
||||
admin: require('./routes/admin'),
|
||||
contact: require('./routes/contact'),
|
||||
progress: require('./routes/progress')
|
||||
};
|
||||
|
||||
// static files
|
||||
app.use('/assets', express.static('assets'));
|
||||
// page map
|
||||
app.use('/', locations.home);
|
||||
app.use('/', locations.contact);
|
||||
app.use('/', locations.posts);
|
||||
app.use('/', locations.admin);
|
||||
app.use('/', locations.progress);
|
||||
app.use((req, res) => {
|
||||
common.sendDefault404(res);
|
||||
utilHelper.send404(res);
|
||||
});
|
||||
|
||||
// TODO improve error handling
|
||||
// TODO remove param decoding errors from logs example: "host/test/%"
|
||||
// 4 parameters required to read the error, cant help the eslint error
|
||||
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
|
||||
console.error(err.stack);
|
||||
return res.status(500).send('Something broke!');
|
||||
});
|
||||
|
||||
// startup
|
||||
|
||||
12
setupAdminUser.js
Normal file
12
setupAdminUser.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const adminUser = require('./models/admin-user');
|
||||
|
||||
const newUser = new adminUser.adminUserModel({
|
||||
username: 'admin',
|
||||
password: 'root'
|
||||
});
|
||||
|
||||
newUser.save().then(() => {
|
||||
console.log('success, user admin pass root');
|
||||
}).catch((rejection) => {
|
||||
console.log(rejection);
|
||||
});
|
||||
4
static-text.example.json
Normal file
4
static-text.example.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"progressSummary": "This is a progress summary",
|
||||
"headerText": "A work-in-progress server replacement for nintendo 3ds and wiiu"
|
||||
}
|
||||
4
static-text.json
Normal file
4
static-text.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"progressSummary": "This is a progress summary",
|
||||
"headerText": "A work-in-progress server replacement for nintendo 3ds and wiiu"
|
||||
}
|
||||
100
views/admin.hbs
100
views/admin.hbs
@@ -20,7 +20,107 @@
|
||||
<button>submit login</button>
|
||||
</form>
|
||||
|
||||
<h2>delete admin</h2>
|
||||
<form action="/admin/api/v1/removeadmin" method="POST">
|
||||
<p>id</p>
|
||||
<input type="text" name="id">
|
||||
<button>delete admin</button>
|
||||
</form>
|
||||
|
||||
<h2>create blog post</h2>
|
||||
<form action="/admin/api/v1/newpost" method="POST">
|
||||
<p>content</p>
|
||||
<textarea name="content" cols="30" rows="10"></textarea>
|
||||
<p>short version</p>
|
||||
<textarea name="short" cols="30" rows="10"></textarea>
|
||||
<p>category</p>
|
||||
<input type="text" name="category">
|
||||
<p>title</p>
|
||||
<input type="text" name="title">
|
||||
<p>author id</p>
|
||||
<input type="text" name="author">
|
||||
<button>create post</button>
|
||||
</form>
|
||||
|
||||
<h2>edit blog post</h2>
|
||||
<form action="/admin/api/v1/editpost" method="POST">
|
||||
<p>content</p>
|
||||
<textarea name="content" cols="30" rows="10"></textarea>
|
||||
<p>short version</p>
|
||||
<textarea name="short" cols="30" rows="10"></textarea>
|
||||
<p>id</p>
|
||||
<input type="text" name="id">
|
||||
<p>category</p>
|
||||
<input type="text" name="category">
|
||||
<p>title</p>
|
||||
<input type="text" name="title">
|
||||
<p>author id</p>
|
||||
<input type="text" name="author">
|
||||
<button>edit post</button>
|
||||
</form>
|
||||
|
||||
<h2>new author</h2>
|
||||
<form action="/admin/api/v1/newauthor" method="POST">
|
||||
<p>name</p>
|
||||
<input type="text" name="name">
|
||||
<p>description</p>
|
||||
<input type="text" name="description">
|
||||
<p>image link</p>
|
||||
<input type="text" name="image">
|
||||
<button>submit author</button>
|
||||
</form>
|
||||
|
||||
<h2>edit author</h2>
|
||||
<form action="/admin/api/v1/editauthor" method="POST">
|
||||
<p>id</p>
|
||||
<input type="text" name="id">
|
||||
<p>name</p>
|
||||
<input type="text" name="name">
|
||||
<p>description</p>
|
||||
<input type="text" name="description">
|
||||
<p>image link</p>
|
||||
<input type="text" name="image">
|
||||
<button>edit author</button>
|
||||
</form>
|
||||
|
||||
<h2>new progress entry</h2>
|
||||
<form action="/admin/api/v1/newprogress" method="POST">
|
||||
<p>title</p>
|
||||
<input type="text" name="title">
|
||||
<p>description</p>
|
||||
<input type="text" name="description">
|
||||
<p>state</p>
|
||||
<p>0 - backend service<br>1 - no support<br>2 - partial support<br>3 - fully working</p>
|
||||
<input type="text" name="state">
|
||||
<button>submit progress</button>
|
||||
</form>
|
||||
|
||||
<h2>edit progress entry</h2>
|
||||
<form action="/admin/api/v1/editprogress" method="POST">
|
||||
<p>id</p>
|
||||
<input type="text" name="id">
|
||||
<p>title</p>
|
||||
<input type="text" name="title">
|
||||
<p>description</p>
|
||||
<input type="text" name="description">
|
||||
<p>state</p>
|
||||
<p>0 - backend service<br>1 - no support<br>2 - partial support<br>3 - fully working</p>
|
||||
<input type="text" name="state">
|
||||
<button>edit progress</button>
|
||||
</form>
|
||||
|
||||
<br>
|
||||
<a href="/admin/api/v1/listadmins">List of admin users</a>
|
||||
<br>
|
||||
<a href="/api/v1/listblog">List of blog posts</a>
|
||||
<br>
|
||||
<a href="/api/v1/listprogress">List of progress</a>
|
||||
<br>
|
||||
<a href="/api/v1/listauthors">List of authors</a>
|
||||
<br>
|
||||
<a href="/admin/api/v1/check">Check if logged in</a>
|
||||
<br>
|
||||
<a href="/admin/api/v1/logout">Logout</a>
|
||||
|
||||
{{> footer }}
|
||||
</body>
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
<body>
|
||||
{{> navbar }}
|
||||
|
||||
<h1>contact form here</h1>
|
||||
<h1>contact form</h1>
|
||||
<form action="/api/v1/sendmessage" method="POST">
|
||||
<p>content</p>
|
||||
<textarea name="message" cols="30" rows="10"></textarea>
|
||||
<p>subject</p>
|
||||
<input type="text" name="subject">
|
||||
<p>email</p>
|
||||
<input type="text" name="email">
|
||||
<button>send message</button>
|
||||
</form>
|
||||
|
||||
{{> footer }}
|
||||
</body>
|
||||
|
||||
7
views/post-collection.hbs
Normal file
7
views/post-collection.hbs
Normal file
@@ -0,0 +1,7 @@
|
||||
<h1>Latest posts</h1>
|
||||
{{#each posts }}
|
||||
<strong>{{ this.title }}</strong>
|
||||
<p>{{ this.content }}</p>
|
||||
<a href="{{ this.url }}">link</a>
|
||||
<br><br>
|
||||
{{/each}}
|
||||
@@ -5,17 +5,18 @@
|
||||
<body>
|
||||
{{> navbar }}
|
||||
|
||||
<h1>blog post</h1>
|
||||
<p>{{ date }}</p>
|
||||
<p>{{ category }}</p>
|
||||
<h1>{{ post.title }}</h1>
|
||||
<p>{{ post.date }}</p>
|
||||
<p>{{ post.category }}</p>
|
||||
|
||||
<article>
|
||||
{{{ content }}}
|
||||
{{{ post.content }}}
|
||||
</article>
|
||||
|
||||
<p>{{ author.name }} - {{ author.description }}</p>
|
||||
<img src="{{ author.image }}">
|
||||
<p>{{ post.author.name }} - {{ post.author.description }}</p>
|
||||
<img src="{{ post.author.profilePicture }}">
|
||||
|
||||
{{> footer }}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
15
views/progress.hbs
Normal file
15
views/progress.hbs
Normal file
@@ -0,0 +1,15 @@
|
||||
<h1>progress summary</h1>
|
||||
<p>{{ summary }}</p>
|
||||
<br>
|
||||
<h2>game support</h2>
|
||||
{{#each games }}
|
||||
<strong>{{ this.title }}</strong>
|
||||
<p>{{ this.description }}</p>
|
||||
<p>state: {{ this.state }}</p>
|
||||
{{/each}}
|
||||
<br><br>
|
||||
<h2>backend support</h2>
|
||||
{{#each backends }}
|
||||
<strong>{{ this.title }}</strong>
|
||||
<p>{{ this.description }}</p>
|
||||
{{/each}}
|
||||
Reference in New Issue
Block a user