Initial commit

This commit is contained in:
hippoz 2020-10-05 20:36:03 +03:00
commit c87c7f98d3
19 changed files with 2498 additions and 0 deletions

64
api/v1/authfunctions.js Executable file
View file

@ -0,0 +1,64 @@
const User = require('../../models/User');
const secret = require('../../secret');
const config = require('../../config');
const jwt = require('jsonwebtoken');
const redirect = (res, status=401, url=undefined) => {
if (!url) {
res.status(status).json({
error: true,
message: 'ERROR_ACCESS_DENIED'
});
return;
}
res.redirect(url);
}
function authenticateEndpoint(callback, url=undefined, minPermissionLevel=config.roleMap.RESTRICTED) {
return (req, res) => {
const token = req.cookies.token;
if (!token) {
redirect(res, 403, url);
return;
}
jwt.verify(token, secret.jwtPrivateKey, {}, async (err, data) => {
if (err) {
redirect(res, 401, url);
return;
}
if (!data) {
redirect(res, 401, url);
return
}
if (!data.username) {
redirect(res, 401, url);
return;
}
const user = await User.findByUsername(data.username);
if (!user) {
redirect(res, 401, url);
return;
}
let permissionLevel = config.roleMap[user.role];
if (!permissionLevel) {
permissionLevel = 0;
}
if (permissionLevel < minPermissionLevel) {
redirect(res, 401, url);
return;
}
callback(req, res, user);
});
};
}
module.exports = { authenticateEndpoint };

140
api/v1/content.js Executable file
View file

@ -0,0 +1,140 @@
const User = require('../../models/User');
const Category = require('../../models/Category');
const Post = require('../../models/Post');
const config = require('../../config');
const secret = require('../../secret');
const { authenticateEndpoint } = require('./authfunctions');
const mongoose = require('mongoose');
const { body, query, param, validationResult } = require('express-validator');
const express = require('express');
const app = express.Router();
mongoose.connect(config.mongoUrl, {useNewUrlParser: true, useUnifiedTopology: true});
const rateLimit = require("express-rate-limit");
const createLimiter = rateLimit({
windowMs: 2 * 60 * 1000,
max: 3,
});
app.post('/category/create', [
createLimiter,
body('title').not().isEmpty().trim().isLength({ min: 3, max: 32 }).escape()
], authenticateEndpoint(async (req, res, user) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ error: true, message: 'ERROR_REQUEST_INVALID_DATA', errors: errors.array() });
return;
}
const title = req.body.title;
const category = await Category.create({
title: title,
creator: user._id,
posts: []
});
res.status(200).json({
error: false,
message: 'SUCCESS_CATEGORY_CREATED',
category: {
title: category.title,
creator: category.creator,
posts: category.posts
}
});
}, undefined, config.roleMap.USER));
app.post('/post/create', [
createLimiter,
body('category').not().isEmpty().trim().escape().isLength({ min: 24, max: 24 }),
body('title').not().isEmpty().trim().isLength({ min: 3, max: 32 }).escape(),
body('body').not().isEmpty().trim().isLength({ min: 3, max: 1000 }).escape(),
], authenticateEndpoint(async (req, res, user) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ error: true, message: 'ERROR_REQUEST_INVALID_DATA', errors: errors.array() });
return;
}
const category = req.body.category;
const title = req.body.title;
const content = req.body.body;
const post = new Post();
post.title = title;
post.body = content;
post.creator = user._id;
post.category = category;
const r = await Category.updateOne({
_id: category
}, {
$push: { posts: post }
});
if (r.n < 1) {
res.status(404).json({
error: true,
message: 'ERROR_CATEGORY_NOT_FOUND'
});
return;
}
res.status(200).json({
error: false,
message: 'SUCCESS_POST_CREATED'
});
}, undefined, config.roleMap.USER));
app.get('/category/:category/info', [
param('category').not().isEmpty().trim().escape().isLength({ min: 24, max: 24 })
], authenticateEndpoint(async (req, res, user) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ error: true, message: 'ERROR_REQUEST_INVALID_DATA', errors: errors.array() });
return;
}
const categoryId = req.params.category;
const category = await Category.findById(categoryId).populate('posts.creator');
if (!category) {
res.status(404).json({
error: true,
message: 'ERROR_CATEGORY_NOT_FOUND'
});
return;
}
res.status(200).json({
error: false,
message: 'SUCCESS_CATEGORY_DATA_FETCHED',
category: {
title: category.title,
creator: category.creator,
posts: category.posts
}
});
}));
app.get('/category/list', authenticateEndpoint(async (req, res, user) => {
let count = parseInt(req.query.count);
if (!Number.isInteger(count)) {
count = 10;
}
// TODO: This is probably not efficient
const categories = await Category.find().sort({ _id: -1 }).limit(count).select('-posts -__v').populate('creator', 'username _id');
res.status(200).json({
error: false,
message: 'SUCCESS_CATEGORY_LIST_FETCHED',
categories
});
}));
module.exports = app;

16
api/v1/index.js Executable file
View file

@ -0,0 +1,16 @@
const usersAPI = require('./users');
const contentAPI = require('./content');
const express = require('express');
const app = express.Router();
app.use('/users', usersAPI);
app.use('/content', contentAPI);
app.get('/', (req, res) => {
// TODO: Add more checks for this, or maybe remove
res.json({ apiStatus: 'OK', apis: [ 'users', 'content' ] });
});
module.exports = app;

191
api/v1/users.js Executable file
View file

@ -0,0 +1,191 @@
const User = require('../../models/User');
const config = require('../../config');
const secret = require('../../secret');
const { authenticateEndpoint } = require('./authfunctions');
// TODO: Might want to use something else (https://blog.benpri.me/blog/2019/01/13/why-you-shouldnt-be-using-bcrypt-and-scrypt/)
const bcrypt = require('bcrypt');
const mongoose = require('mongoose');
const { body, query, param, validationResult } = require('express-validator');
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express.Router();
mongoose.connect(config.mongoUrl, {useNewUrlParser: true, useUnifiedTopology: true});
const rateLimit = require("express-rate-limit");
const createAccountLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour window
max: 5, // start blocking after 5 requests
message: "You are being rate limited"
});
app.post('/account/create', [
createAccountLimiter,
body('username').not().isEmpty().trim().isLength({ min: 3, max: 32 }).isAlphanumeric(),
body('email').not().isEmpty().isEmail().normalizeEmail(),
body('password').not().isEmpty().isLength({ min: 8, max: 128 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ error: true, message: 'ERROR_REQUEST_INVALID_DATA', errors: errors.array() });
return;
}
const username = req.body.username;
const existingUser = await User.findByUsername(username);
if (existingUser) {
res.status(400).json({ error: true, message: 'ERROR_REQUEST_USERNAME_EXISTS', errors: [{ value: username, msg: 'Username exists', param: 'username', location: 'body' }] });
return;
}
const unhashedPassword = req.body.password;
const email = req.body.email;
const startingRole = 'USER';
const hashedPassword = await bcrypt.hash(unhashedPassword, config.bcryptRounds);
const user = await User.create({
username,
email,
password: hashedPassword,
role: startingRole
});
res.status(200).json({
error: false,
message: 'SUCCESS_USER_CREATED',
user: {
_id: user._id,
username: user.username,
email: user.email,
role: user.role,
permissionLevel: config.roleMap[user.role]
}
});
} catch (e) {
console.error('Internal server error', e);
res.status(500).json({ error: true, message: 'INTERNAL_SERVER_ERROR' });
return;
}
});
app.post('/token/create', [
createAccountLimiter,
body('username').not().isEmpty().trim().isAlphanumeric(),
body('password').not().isEmpty()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ error: true, message: 'ERROR_REQUEST_LOGIN_INVALID' });
return;
}
const username = req.body.username;
const existingUser = await User.findByUsername(username);
if (!existingUser) {
res.status(403).json({ error: true, message: 'ERROR_REQUEST_LOGIN_INVALID' });
return;
}
const password = req.body.password;
let passwordCheck;
try {
passwordCheck = await bcrypt.compare(password, existingUser.password);
} catch(e) {
passwordCheck = false;
}
if (!passwordCheck) {
res.status(403).json({ error: true, message: 'ERROR_REQUEST_LOGIN_INVALID' });
return;
}
jwt.sign({ username }, secret.jwtPrivateKey, { expiresIn: '3h' }, (err, token) => {
if (err) {
res.status(500).json({
error: true,
message: 'INTERNAL_SERVER_ERROR'
});
return;
}
// TODO: Ugly fix for setting httponly cookies
if (req.body.alsoSetCookie) {
res.cookie('token', token, {
maxAge: 3 * 60 * 60 * 1000, httpOnly: true, domain: config.address,
});
}
res.status(200).json({
error: false,
message: 'SUCCESS_TOKEN_CREATED',
user: {
_id: existingUser._id,
username: existingUser.username,
email: existingUser.email,
role: existingUser.role,
permissionLevel: config.roleMap[existingUser.role]
},
token
});
});
});
app.get('/current/info', authenticateEndpoint((req, res, user) => {
res.status(200).json({
error: false,
message: 'SUCCESS_USER_DATA_FETCHED',
user: {
_id: user._id,
username: user.username,
email: user.email,
role: user.role,
permissionLevel: config.roleMap[user.role]
},
});
}, undefined, 0));
app.get('/user/:userid/info', [
param('userid').not().isEmpty().trim().escape().isLength({ min: 24, max: 24 })
], authenticateEndpoint(async (req, res, user) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ error: true, message: 'ERROR_REQUEST_INVALID_DATA', errors: errors.array() });
return;
}
const userid = req.params.userid;
if (!userid) {
res.sendStatus(400).json({
error: true,
message: 'ERROR_REQUEST_INVALID_DATA'
});
return;
}
const otherUser = await User.findById(userid);
res.status(200).json({
error: false,
message: 'SUCCESS_USER_DATA_FETCHED',
user: {
_id: otherUser._id,
username: otherUser.username,
role: otherUser.role
},
});
}));
app.post('/browser/token/clear', authenticateEndpoint((req, res, user) => {
res.clearCookie('token');
res.sendStatus(200);
}));
module.exports = app;

57
apitest.rest Executable file
View file

@ -0,0 +1,57 @@
POST http://localhost:3000/api/v1/users/account/create
Content-Type: application/json
{
"username": "test",
"password": "testtesttest",
"email": "test@test.test"
}
###
POST http://localhost:3000/api/v1/users/token/create
Content-Type: application/json
{
"username": "test",
"password": "testtesttest"
}
###
GET http://localhost:3000/api/v1/users/current/info
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJpYXQiOjE2MDA1MTA2MTEsImV4cCI6MTYwMDUyMTQxMX0.q85p94FLPR4fxZ4O5pmalEEjU9Hyr9js63u6LgoCQCw
###
POST http://localhost:3000/api/v1/content/category/create
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJpYXQiOjE2MDA1MTA2MTEsImV4cCI6MTYwMDUyMTQxMX0.q85p94FLPR4fxZ4O5pmalEEjU9Hyr9js63u6LgoCQCw
Content-Type: application/json
{
"name": "testing1"
}
###
POST http://localhost:3000/api/v1/content/post/create
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJpYXQiOjE2MDA1MTA2MTEsImV4cCI6MTYwMDUyMTQxMX0.q85p94FLPR4fxZ4O5pmalEEjU9Hyr9js63u6LgoCQCw
Content-Type: application/json
{
"category": "5f65e1f05c3cdd86400f43ec",
"title": "Test title",
"content": "Test content!!!"
}
###
GET http://localhost:3000/api/v1/content/category/5f65e1f05c3cdd86400f43ec/info
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJpYXQiOjE2MDA1MTA2MTEsImV4cCI6MTYwMDUyMTQxMX0.q85p94FLPR4fxZ4O5pmalEEjU9Hyr9js63u6LgoCQCw
Content-Type: application/json
###
GET http://localhost:3000/api/v1/content/category/list
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJpYXQiOjE2MDA1MTA2MTEsImV4cCI6MTYwMDUyMTQxMX0.q85p94FLPR4fxZ4O5pmalEEjU9Hyr9js63u6LgoCQCw
Content-Type: application/json

138
app/app.html Executable file
View file

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>App</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700,400italic|Material+Icons">
<link rel="stylesheet" href="https://unpkg.com/vue-material/dist/vue-material.min.css">
<link rel="stylesheet" href="https://unpkg.com/vue-material/dist/theme/default-dark.css">
<script defer src="https://unpkg.com/vue-material"></script>
<script defer src="./resources/js/app.js"></script>
<style>
.md-card {
width: 312px;
margin: 4px;
display: inline-block;
vertical-align: top;
}
.cursor {
cursor: pointer;
}
</style>
</head>
<body>
<div id="app">
<div v-if="showApp">
<md-dialog id="create-category-dialog" :md-active.sync="dialog.show.createCategory">
<md-dialog-title>Create category</md-dialog-title>
<md-dialog-content>
<md-field>
<label>Title</label>
<md-input v-model="dialog.text.createCategory.title"></md-input>
</md-field>
<md-dialog-actions>
<md-button @click="dialog.show.createCategory = false">Close</md-button>
<md-button class="md-primary" @click="createCategory()">Create</md-button>
</md-dialog-actions>
</md-dialog-content>
</md-dialog>
<md-dialog id="create-post-dialog" :md-active.sync="dialog.show.createPost">
<md-dialog-title>Create post for <strong>{{ selection.category.title }}</strong></md-dialog-title>
<md-dialog-content>
<md-field>
<label>Title</label>
<md-input v-model="dialog.text.createPost.title"></md-input>
</md-field>
<md-field>
<label>Content</label>
<md-textarea v-model="dialog.text.createPost.body" md-counter="1000"></md-textarea>
</md-field>
<md-dialog-actions>
<md-button @click="dialog.show.createPost = false">Close</md-button>
<md-button class="md-primary" @click="createPost()">Create</md-button>
</md-dialog-actions>
</md-dialog-content>
</md-dialog>
<md-dialog id="user-info-dialog" :md-active.sync="viewingProfile.show">
<md-dialog-title><strong>{{ viewingProfile.username }}</strong></md-dialog-title>
<md-dialog-content>
<p>Role: {{ viewingProfile.role }}</p>
<md-dialog-actions>
<md-button @click="viewingProfile.show = false">Close</md-button>
</md-dialog-actions>
</md-dialog-content>
</md-dialog>
<md-toolbar class="md-accent" md-elevation="5">
<h3 class="md-title" style="flex: 1">Brainlet</h3>
<md-menu md-size="small">
<md-button md-menu-trigger>{{ loggedInUser.username }}</md-button>
<md-menu-content>
<md-menu-item @click="navigateToAccountManager()">Manage account</md-menu-item>
</md-menu-content>
</md-menu>
</md-toolbar>
<md-toolbar v-show="selection.category.browsing" class="md-dense" md-elevation="5">
<h3 v-if="selection.category.isCategory" class="md-title" style="flex: 1">Browsing category: {{ selection.category.title }}</h3>
<h3 v-if="!selection.category.isCategory" class="md-title" style="flex: 1">Browsing {{ selection.category.title }}</h3>
<md-button @click="browseCategories()" v-if="selection.category.isCategory">Back</md-button>
<md-button @click="refresh()">Refresh</md-button>
</md-toolbar>
<div id="posts-container" v-if="selection.category.browsing">
<md-card v-for="post in selection.posts">
<md-card-header>
<div class="md-title" v-html="post.title"></div>
<span>by <a class="md-dense cursor" v-on:click="viewProfile(post.creator._id)">{{ post.creator.username }}</a></span>
</md-card-header>
<md-card-content v-html="post.body"></md-card-content>
<md-card-actions>
<md-button v-for="button in cardButtons" @click="button.click(post)">{{ button.text }}</md-button>
</md-card-actions>
</md-card>
</div>
<md-speed-dial class="md-fixed md-bottom-right" md-direction="top" md-elevation="5" style="z-index: 4000;">
<md-speed-dial-target>
<md-icon class="md-morph-initial">add</md-icon>
<md-icon class="md-morph-final">edit</md-icon>
</md-speed-dial-target>
<md-speed-dial-content>
<md-button v-show="selection.category.isCategory" class="md-icon-button" @click="showCreatePostDialog()">
<md-icon>add</md-icon>
<md-tooltip md-direction="left">Create a new post</md-tooltip>
</md-button>
<md-button class="md-icon-button" @click="dialog.show.createCategory = true">
<md-icon>category</md-icon>
<md-tooltip md-direction="left">Create a new category</md-tooltip>
</md-button>
</md-speed-dial-content>
</md-speed-dial>
</div>
<md-snackbar md-position="center" :md-duration="snackbarNotificationDuration" :md-active.sync="showSnackbarNotification" md-persistent>
<span>{{ snackbarNotification }}</span>
<md-button class="md-primary" @click="snackbarButtonClick()">{{ snackbarButtonText }}</md-button>
</md-snackbar>
</div>
</body>
</html>

71
app/auth.html Executable file
View file

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700,400italic|Material+Icons">
<link rel="stylesheet" href="https://unpkg.com/vue-material/dist/vue-material.min.css">
<link rel="stylesheet" href="https://unpkg.com/vue-material/dist/theme/default-dark.css">
<script defer src="https://unpkg.com/vue-material"></script>
<script defer src="./resources/js/auth.js"></script>
</head>
<body>
<div id="app">
<div class="topbar-container">
<md-toolbar class="md-accent" md-elevation="5">
<h3 class="md-title" style="flex: 1">Brainlet</h3>
<md-button @click="navigateHome()" v-if="loggedInUser.username">Home</md-button>
</md-toolbar>
</div>
<div class="md-layout md-alignment-top-center" v-if="!successfulLogin">
<md-card class="md-layout-item md-size-25 md-medium-size-100">
<md-card-header>
<div class="md-title">{{ modeName }}</div>
</md-card-header>
<md-card-content>
<div v-if="mode === 'LOGIN' || mode === 'SIGNUP'">
<div>
<md-field>
<label>Username</label>
<md-input v-model="usernameInput"></md-input>
</md-field>
</div>
<div v-if="mode === 'SIGNUP'">
<md-field>
<label>Email</label>
<md-input v-model="emailInput"></md-input>
</md-field>
</div>
<div>
<md-field>
<label>Password</label>
<md-input v-model="passwordInput" type="password"></md-input>
</md-field>
</div>
</div>
</md-card-content>
<md-card-actions>
<md-button v-if="mode === 'SIGNUP'" @click="mode='LOGIN'" class="md-dense">Log in instead</md-button>
<md-button v-if="mode === 'LOGIN'" @click="mode='SIGNUP'" class="md-dense">Sign up instead</md-button>
<md-button v-if="mode === 'SIGNUP'" class="md-dense md-raised md-primary" @click="performAccountCreation()">Sign up</md-button>
<md-button v-if="mode === 'LOGIN'" class="md-dense md-raised md-primary" @click="performTokenCreation()">Log in</md-button>
<md-button v-if="mode === 'MANAGE'" class="md-dense md-raised" @click="performTokenRemoval()">Log out</md-button>
</md-card-actions>
</md-card>
</div>
<md-snackbar md-position="center" :md-duration="snackbarNotificationDuration" :md-active.sync="showSnackbarNotification" md-persistent>
<span>{{ snackbarNotification }}</span>
<md-button class="md-primary" @click="snackbarButtonClick()">{{ snackbarButtonText }}</md-button>
</md-snackbar>
</div>
</body>
</html>

0
app/resources/css/base.css Executable file
View file

357
app/resources/js/app.js Executable file
View file

@ -0,0 +1,357 @@
Vue.use(VueMaterial.default);
const getCreatePostError = (json) => {
switch (json.message) {
case 'ERROR_REQUEST_INVALID_DATA': {
switch (json.errors[0].param) {
case 'title': {
return 'Invalid title. Must be between 3 and 32 characters.';
}
case 'body': {
return 'Invalid content. Must be between 3 and 1000 characters';
}
case 'category': {
return 'Invalid category. Something went wrong.';
}
default: {
return 'Invalid value sent to server. Something went wrong.';
}
}
}
case 'ERROR_CATEGORY_NOT_FOUND': {
return 'The category you tried to post to no longer exists.';
}
case 'ERROR_ACCESS_DENIED': {
return 'You are not allowed to perform this action.'
}
default: {
return 'Unknown error. Something went wrong.';
}
}
}
const getCreateCategoryError = (json) => {
switch (json.message) {
case 'ERROR_REQUEST_INVALID_DATA': {
switch (json.errors[0].param) {
case 'title': {
return 'Invalid title. Title must be between 3 and 32 characters.';
}
}
}
case 'ERROR_ACCESS_DENIED': {
return 'You are not allowed to perform this action.'
}
default: {
return 'Unknown error. Something went wrong.';
}
}
}
const app = new Vue({
el: '#app',
data: {
showSnackbarNotification: false,
snackbarNotification: '',
snackbarNotificationDuration: 999999,
snackbarButtonText: 'Ok',
loggedInUser: {},
showApp: false,
menuVisible: false,
selection: {
category: {
title: '',
browsing: false,
_id: undefined,
isCategory: false
},
posts: []
},
cardButtons: [],
dialog: {
show: {
createPost: false,
createCategory: false
},
text: {
createPost: {
title: '',
body: ''
},
createCategory: {
title: ''
}
}
},
viewingProfile: {
show: false,
_id: '',
username: '',
role: ''
}
},
mounted: async function() {
const res = await fetch(`${window.location.origin}/api/v1/users/current/info`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
credentials: 'include'
});
if (res.status === 200) {
const json = await res.json();
if (json.user.permissionLevel >= 1) {
this.loggedInUser = json.user;
this.showApp = true;
this.browseCategories();
} else {
this.showApp = false;
this.snackbarEditButton('Manage', () => {
window.location.href = `${window.location.origin}/auth.html`;
this.resetSnackbarButton();
this.showSnackbarNotification = false;
});
this.notification('Your account does not have the required permissions to enter this page');
}
} else {
this.showApp = false;
this.snackbarEditButton('Manage', () => {
window.location.href = `${window.location.origin}/auth.html`;
this.resetSnackbarButton();
this.showSnackbarNotification = false;
});
this.notification('You are not logged in or your session is invalid');
}
},
methods: {
navigateToAccountManager() {
window.location.href = `${window.location.origin}/auth.html`;
},
snackbarButtonAction: function() {
this.showSnackbarNotification = false;
},
snackbarButtonClick: function() {
this.snackbarButtonAction();
},
snackbarEditButton: function(buttonText="Ok", action) {
this.snackbarButtonText = buttonText;
this.snackbarButtonAction = action;
},
resetSnackbarButton: function() {
this.snackbarButtonText = 'Ok';
this.snackbarButtonAction = () => {
this.showSnackbarNotification = false;
};
},
notification: function(text) {
this.snackbarNotification = text;
this.showSnackbarNotification = true;
},
button: function(text, click) {
this.cardButtons.push({ text, click });
},
refresh: function() {
if (this.selection.category.title === 'categories' && this.selection.category.isCategory === false) {
this.browseCategories();
} else {
this.browse(this.selection.category);
}
},
viewProfile: async function(id) {
// TODO: this just returns the username for now
const res = await fetch(`${window.location.origin}/api/v1/users/user/${id}/info`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
credentials: 'include'
});
if (res.status === 200) {
const json = await res.json();
this.viewingProfile.username = json.user.username;
this.viewingProfile._id = json.user._id;
this.viewingProfile.role = json.user.role;
this.viewingProfile.show = true;
} else {
this.resetSnackbarButton();
this.notification('Failed to fetch user data');
}
},
stopBrowsing: function() {
this.selection.category = {
title: '',
browsing: false,
_id: undefined,
isCategory: false
};
this.selection.posts = [];
this.cardButtons = [];
},
showCreatePostDialog: function() {
if (!this.selection.category.isCategory) {
this.resetSnackbarButton();
this.notification('You are not in a category');
return;
}
this.dialog.show.createPost = true;
},
createPost: async function() {
if (!this.selection.category.isCategory) {
this.resetSnackbarButton();
this.notification('You are not in a category');
return;
}
const category = this.selection.category;
const input = this.dialog.text.createPost;
const res = await fetch(`${window.location.origin}/api/v1/content/post/create`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
category: category._id,
title: input.title,
body: input.body
})
});
if (res.status !== 200) {
if (res.status === 401 || res.status === 403) {
this.resetSnackbarButton();
this.notification('You are not allowed to do that');
return;
}
if (res.status === 429) {
this.resetSnackbarButton();
this.notification('Chill! You are posting too much!');
return;
}
const json = await res.json();
this.resetSnackbarButton();
this.notification(getCreatePostError(json));
return;
} else {
this.resetSnackbarButton();
this.notification('Successfully created post');
this.dialog.show.createPost = false;
this.browse(this.selection.category);
return;
}
},
createCategory: async function() {
const input = this.dialog.text.createCategory;
const res = await fetch(`${window.location.origin}/api/v1/content/category/create`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
title: input.title
})
});
if (res.status !== 200) {
if (res.status === 401 || res.status === 403) {
this.resetSnackbarButton();
this.notification('You are not allowed to do that');
return;
}
if (res.status === 429) {
this.resetSnackbarButton();
this.notification('Chill! You are posting too much!');
return;
}
const json = await res.json();
this.resetSnackbarButton();
this.notification(getCreateCategoryError(json));
return;
} else {
this.resetSnackbarButton();
this.notification('Successfully created category');
this.dialog.show.createCategory = false;
this.browseCategories();
return;
}
},
browse: async function(category) {
const { _id, title } = category;
const res = await fetch(`${window.location.origin}/api/v1/content/category/${_id}/info`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
credentials: 'include'
});
if (res.status === 200) {
const json = await res.json();
this.selection.category.title = title;
this.selection.category._id = _id;
this.selection.category.browsing = true;
this.selection.category.isCategory = true;
this.selection.posts = [];
this.cardButtons = [];
for (let i = 0; i < json.category.posts.length; i++) {
const v = json.category.posts[i];
this.selection.posts.push({ title: v.title, body: v.body, _id: v._id, creator: v.creator });
}
} else {
this.resetSnackbarButton();
this.notification('Failed to fetch category');
}
},
browseCategories: async function() {
const res = await fetch(`${window.location.origin}/api/v1/content/category/list?count=50`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
credentials: 'include'
});
if (res.status === 200) {
const json = await res.json();
this.selection.category.title = 'categories';
this.selection.category.browsing = true;
this.selection.category.isCategory = false;
this.selection.posts = [];
this.cardButtons = [];
this.button('View', (post) => {
this.browse(post);
});
for (let i = 0; i < json.categories.length; i++) {
const v = json.categories[i];
this.selection.posts.push({ title: v.title, body: '', _id: v._id, creator: v.creator });
}
} else {
this.resetSnackbarButton();
this.notification('Failed to fetch category list');
}
}
}
});

227
app/resources/js/auth.js Executable file
View file

@ -0,0 +1,227 @@
Vue.use(VueMaterial.default);
const getLoginMessageFromError = (json) => {
switch (json.message) {
case 'ERROR_REQUEST_LOGIN_INVALID': {
return 'Invalid username or password.';
}
case 'ERROR_ACCESS_DENIED': {
return 'You are not allowed to perform this action.'
}
default: {
return 'Unknown error. Something went wrong.'
}
}
}
const getSignupMessageFromError = (json) => {
switch (json.message) {
case 'ERROR_REQUEST_INVALID_DATA': {
switch (json.errors[0].param) {
case 'username': {
return 'Invalid username. Username must be between 3 and 32 characters long, and be alphanumeric.';
}
case 'password': {
return 'Invalid password. Password must be at least 8 characters long and at most 128 characters.';
}
case 'email': {
return 'Invalid email.';
}
default: {
return 'Invalid value sent to server. Something went wrong.';
}
}
}
case 'ERROR_ACCESS_DENIED': {
return 'You are not allowed to perform this action.'
}
case 'ERROR_REQUEST_USERNAME_EXISTS': {
return 'That username is taken.';
}
default: {
return 'Unknown error. Something went wrong.'
}
}
};
const app = new Vue({
el: '#app',
data: {
usernameInput: '',
emailInput: '',
passwordInput: '',
mode: 'SIGNUP',
showSnackbarNotification: false,
snackbarNotification: '',
snackbarNotificationDuration: 999999,
snackbarButtonText: 'Ok',
successfulLogin: false,
loggedInUser: {}
},
mounted: async function() {
const res = await fetch(`${window.location.origin}/api/v1/users/current/info`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
credentials: 'include'
});
if (res.status === 200) {
const json = await res.json();
if (json.user.permissionLevel >= 1) {
this.loggedInUser = json.user;
this.mode = 'MANAGE';
} else {
this.resetSnackbarButton();
this.notification('Your account has been suspended');
this.successfulLogin = true;
}
} else {
this.mode = 'SIGNUP';
}
},
computed: {
modeName: function() {
switch (this.mode) {
case 'SIGNUP': {
return 'Sign up';
}
case 'LOGIN': {
return 'Log in';
}
case 'LOADING': {
return 'Loading...';
}
case 'MANAGE': {
return this.loggedInUser.username || 'Unknown account';
}
case 'NONE': {
return '';
}
default: {
return 'Something went wrong.';
}
}
}
},
methods: {
snackbarButtonAction: function() {
this.showSnackbarNotification = false;
},
snackbarButtonClick: function() {
this.snackbarButtonAction();
},
snackbarEditButton: function(buttonText="Ok", action) {
this.snackbarButtonText = buttonText;
this.snackbarButtonAction = action;
},
resetSnackbarButton: function() {
this.snackbarButtonText = 'Ok';
this.snackbarButtonAction = () => {
this.showSnackbarNotification = false;
};
},
notification: function(text) {
this.snackbarNotification = text;
this.showSnackbarNotification = true;
},
performTokenRemoval: async function() {
const res = await fetch(`${window.location.origin}/api/v1/users/browser/token/clear`, {
method: 'POST',
credentials: 'include'
});
if (res.status === 200) {
this.loggedInUser = {};
this.snackbarEditButton('Home', () => {
window.location.href = `${window.location.origin}`;
this.resetSnackbarButton();
this.showSnackbarNotification = false;
});
this.successfulLogin = true;
this.notification('Successfully logged out');
} else {
this.resetSnackbarButton();
this.notification('Could not log out');
}
},
navigateHome: function() {
window.location.href = `${window.location.origin}`;
},
performTokenCreation: async function() {
const res = await fetch(`${window.location.origin}/api/v1/users/token/create`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
username: this.usernameInput,
password: this.passwordInput,
alsoSetCookie: true
})
});
const json = await res.json();
if (json.error || res.status !== 200) {
this.resetSnackbarButton();
this.notification(getLoginMessageFromError(json));
return;
} else {
document.cookie = `token=${json.token}; HTTPOnly=true; max-age=10800`;
this.successfulLogin = true;
this.snackbarEditButton('Home', () => {
this.navigateHome();
this.resetSnackbarButton();
this.showSnackbarNotification = false;
});
this.notification(`Successfully logged into account "${json.user.username}"`);
this.loggedInUser = { username: json.user.username, _id: json.user._id };
return;
}
},
performAccountCreation: async function() {
const res = await fetch(`${window.location.origin}/api/v1/users/account/create`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: this.usernameInput,
email: this.emailInput,
password: this.passwordInput
})
});
const json = await res.json();
console.log(json);
if (json.error || res.status !== 200) {
this.resetSnackbarButton();
this.notification(getSignupMessageFromError(json));
return;
} else {
this.snackbarEditButton('Login', () => {
this.mode = 'LOGIN';
this.resetSnackbarButton();
this.showSnackbarNotification = false;
});
this.notification(`Account "${json.user.username}" successfully created`);
return;
}
}
}
});

14
config.js Executable file
View file

@ -0,0 +1,14 @@
module.exports = {
ports: {
mainServerPort: 25,
},
address: '188.25.251.46',
mongoUrl: 'mongodb://localhost:27017/app',
bcryptRounds: 10,
roleMap: {
'BANNED': 0,
'RESTRICTED': 1,
'USER': 2,
'ADMIN': 3
}
};

33
index.js Executable file
View file

@ -0,0 +1,33 @@
const config = require('./config');
const apiRoute = require('./api/v1');
const express = require('express');
const cookieParser = require('cookie-parser');
const cors = require('cors')
const { authenticateEndpoint } = require('./api/v1/authfunctions');
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(cookieParser());
app.use(cors({
origin: `http://${config.address}`,
credentials: true,
optionsSuccessStatus: 200
}));
app.use('/api/v1', apiRoute);
app.use(express.static('app'));
app.get('/', authenticateEndpoint((req, res, user) => {
res.redirect('/app.html');
}, `/auth.html`));
app.get('/admin', (req, res) => {
res.send('Keanu chungus wholesome 100 reddit moment 😀i beat up a kid that said minecraft bad 😂and my doggo bit him so i gave him snaccos😉 and we watched pewdiepie together while in elon musks cyber truck 😳talking about how superior reddit memers are : “haha emojis bad” 😲i said and keanu reeves came outta nowhere and said “this is wholesome 100, updoot this wholesome boy” 😗so i got alot of updoots and edit: thanks for the gold kind stranger😣. but the kind stranger revealed himself to be baby yoda eating chiccy nuggies😨 and drinking choccy milk😎 so we went to the cinema to see our (communism funny) favorite movies avengers endgame😆 but then thor played fortnite and fortnite bad😡, so then i said “reality is often dissappointing” and then baby yoda replied r/unexpectedthanos and i replied by r/expectedthanos😖 for balance and then danny devito came to pick us up from the cinema😩 and all the insta normies and gay mods stood watching😵 ,as we,superior redditors went home with danny devito to suck on his magnum dong😫 but i said no homo and started sucking,not like those gay mods😮,then the next morning we woke up to MrBeast telling us to plant 69420 million trees😌, me, baby yoda and danny said nice, and then on our way to plant 69420 million trees😊 (nice) we saw a kid doing a tiktok so keanu reeves appeared and said “we have a kid to burn” and i replied “youre breathtaking”😄 so i said “i need a weapon” and baby yoda gave me an RPG so i blew the kid (DESTRUCTION 100)😎 and posted it on r/memes and r/dankmemes and r/pewdiepiesubmissions and got 1000000000 updoots😘,im sure pewds will give me a big pp, then we shat on emoji users😂😂 and started dreaming about girls that will never like me😢 and posted a lie on r/teenagers about how i got a GF after my doggo died by the hands of fortnite players😳 so i exploited his death for updoots😜, but i watched the sunset with the wholesome gang😁 (keanu,danny,Mrbeast, pewds, spongebob,stefan karl , bob ross, steve irwin, baby yoda and other artists that reddit exploits them) [Everyone liked that] WHOLESOME 100 REDDIT 100🤡');
});
app.listen(config.ports.mainServerPort, () => {
console.log(`Main server is listening on port ${config.ports.mainServerPort}`);
});

10
models/Category.js Executable file
View file

@ -0,0 +1,10 @@
const mongoose = require('mongoose');
const Post = require('./Post');
const Category = mongoose.model('Category', {
title: String,
creator: {type: mongoose.Schema.Types.ObjectId, ref: 'User'},
posts: [Post.schema]
});
module.exports = Category;

10
models/Post.js Executable file
View file

@ -0,0 +1,10 @@
const mongoose = require('mongoose');
const Post = mongoose.model('Post', {
title: String,
body: String,
creator: {type: mongoose.Schema.Types.ObjectId, ref: 'User'},
categoryId: {type: mongoose.Schema.Types.ObjectId, ref: 'Category'}
});
module.exports = Post;

14
models/User.js Executable file
View file

@ -0,0 +1,14 @@
const mongoose = require('mongoose');
const User = mongoose.model('User', {
username: String,
password: String,
email: String,
role: String
});
User.findByUsername = async function(username) {
return await User.findOne({ username }).exec();
};
module.exports = User;

3
notes.txt Executable file
View file

@ -0,0 +1,3 @@
vulns:
- you can send a malformed request and express will send the full error
- 2 users can have the same name if one of the letters is uppercase

1128
package-lock.json generated Executable file

File diff suppressed because it is too large Load diff

22
package.json Executable file
View file

@ -0,0 +1,22 @@
{
"name": "dictionar",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcrypt": "^5.0.0",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-rate-limit": "^5.1.3",
"express-validator": "^6.6.1",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.10.0"
}
}

3
secret.js.template Executable file
View file

@ -0,0 +1,3 @@
module.exports = {
jwtPrivateKey: 'KEY'
};