Compare commits

...

4 Commits

Author SHA1 Message Date
jane 40ca80b654 split todo backend from frontend 2021-09-22 19:08:13 -04:00
jane 9c68f9748e move to sveltekit 2021-09-18 13:32:52 -04:00
jane ed727faec9 continue svelte exploration 2021-08-18 23:39:53 -04:00
jane a297a965aa switch to rust, implement discord login and switch to svelte 2021-08-18 15:21:33 -04:00
69 changed files with 3372 additions and 2140 deletions

9
.gitignore vendored
View File

@ -42,6 +42,11 @@ build/Release
node_modules/
jspm_packages/
/public/build/
.DS_Store
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
@ -108,6 +113,7 @@ dist
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
.vscode/
# yarn v2
.yarn/cache
@ -127,3 +133,6 @@ pnpm-lock.yaml
backend/config.json
*.pem
.env
test_coverage/
target/

2534
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

38
Cargo.toml Normal file
View File

@ -0,0 +1,38 @@
[package]
edition = "2018"
name = "backend"
version = "0.1.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-lock = "2.4.0"
async-redis-session = {git = "https://github.com/jbr/async-redis-session", version = "0.2.2"}
async-session = "3.0.0"
async-std = {version = "1.9.0", features = ["attributes"]}
axum = {version = "0.1.1", features = ["headers"]}
chrono = {version = "0.4.0", features = ["serde"]}
diesel = {version = "1.4.7", features = ["postgres", "chrono", "serde_json", "r2d2", "uuidv07"]}
diesel-tracing = {version = "0.1.5", features = ["postgres"]}
diesel_migrations = {version = "1.4.0"}
dotenv = "0.15.0"
fern = "0.6.0"
headers = "0.3.4"
http = "0.2.4"
hyper = {version = "0.14.11", features = ["full"]}
log = "0.4.14"
oauth2 = "4.1.0"
redis = {version = "0.21.0", features = ["aio", "async-std-comp"]}
reqwest = {version = "0.11.4", features = ["json"]}
serde = {version = "1.0.127", features = ["derive"]}
serde-redis = "0.10.0"
serde_json = "1.0.66"
tokio = {version = "1.9.0", features = ["full"]}
tokio-diesel = {git = "https://github.com/mehcode/tokio-diesel", version = "0.3.0"}
tower = {version = "0.4.6", features = ["full"]}
tower-http = {version = "0.1.1", features = ["trace"]}
tracing = {version = "0.1.26", features = ["log-always"]}
tracing-log = {version = "0.1.2", features = ["log-tracer"]}
tracing-subscriber = {version = "0.2.20", features = ["fmt"]}
url = "2.2.2"
uuid = {version = "0.8.2", features = ["serde", "v4"]}

View File

@ -1,16 +0,0 @@
arrowParens: 'always'
bracketSpacing: true
endOfLine: 'lf'
htmlWhitespaceSensitivity: 'css'
insertPragma: false
jsxBracketSameLine: true
jsxSingleQuote: true
printWidth: 120
proseWrap: 'preserve'
quoteProps: 'consistent'
requirePragma: false
semi: true
singleQuote: true
tabWidth: 2
trailingComma: 'none'
useTabs: false

View File

@ -1,2 +0,0 @@
# todo

View File

@ -1,16 +0,0 @@
{
"secret": "TEST_SECRET",
"https": true,
"alter_db": true,
"port": 8080,
"frontend_url": "localhost:3000",
"db_url": "postgres://postgres:@127.0.0.1/todo",
"cert": "",
"cert_key": "",
"mail_host": "",
"mail_port": 465,
"mail_username": "",
"mail_password": "",
"discord_id": "",
"discord_secret": ""
}

View File

@ -1,36 +0,0 @@
{
"name": "todo",
"version": "1.0.0",
"description": "todo list app (because it hasnt been done before)",
"main": "src/index.js",
"scripts": {
"who": "pwd",
"start": "node src/index.js",
"test": "mocha"
},
"repository": {
"type": "git",
"url": "git@ssh.gitdab.com:jane/todo.git"
},
"author": "jane <jane@j4.pm>",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-paginate": "^1.0.2",
"http-proxy": "^1.18.1",
"node-fetch": "^2.6.1",
"nodemailer": "^6.6.2",
"pg": "^8.6.0",
"sequelize": "^6.6.5"
},
"devDependencies": {
"chai": "^4.3.4",
"mocha": "^9.0.2",
"proxyrequire": "^1.0.21",
"sequelize-cli": "^6.2.0",
"sequelize-test-helpers": "^1.3.3",
"sinon": "^11.1.1"
}
}

View File

@ -1,21 +0,0 @@
const fs = require('fs');
if (!global.config) {
global.config = {}
const cfg = JSON.parse(fs.readFileSync('./config.json'));
if (cfg) {
global.config = cfg;
}
}
class Config {
get config() {
return global.config;
}
set config(dat) {
global.config = dat;
}
}
module.exports = new Config();

View File

@ -1,20 +0,0 @@
const Sequelize = require('sequelize');
const Config = require('./config.js');
const Models = require('./models');
if (!Config.config.db_url) {
console.error('No database url found. please set `db_url` in config.json');
process.exit();
}
const db = new Sequelize(Config.config.db_url);
module.exports = {
db: db,
constructors: {
user: () => {
return User.build();
}
},
schemas: Models(db, Sequelize)
};

View File

@ -1,70 +0,0 @@
const http = require('http');
const https = require('https');
const httpProxy = require('http-proxy');
const cors = require('cors');
const express = require('express');
const cookieParser = require('cookie-parser');
const fs = require('fs');
const Config = require('./config.js');
const UserInterface = require('./user.js');
const TodoInterface = require('./todo.js');
let credentials = {};
if (Config.config.https) {
if (fs.existsSync(Config.config.cert) && fs.existsSync(Config.config.cert_key)) {
credentials.key = fs.readFileSync(Config.config.cert_key);
credentials.cert = fs.readFileSync(Config.config.cert);
}
else {
console.error('could not load certs')
process.exit()
}
}
let app = express();
app.use(cors());
app.use(cookieParser());
// force https
app.use((req, res, next) => {
if (Config.config.https) {
if (req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect(`https://${req.headers.host}${req.url}`);
}
}
return next();
});
if (!Config.config.secret) {
console.error('No password secret found. please set `secret` in config.json');
process.exit();
} else if (Config.config.https && Config.config.secret == 'TEST_SECRET') {
console.error('please do not use the testing secret in production.');
process.exit();
}
app.use('/api/user', UserInterface.router);
app.use('/api/todo', TodoInterface.router);
if (Config.config.frontend_url) {
const proxy = httpProxy.createProxyServer({})
app.use('/', (req, res) => {
return proxy.web(req, res, {
target: Config.config.frontend_url
})
});
}
if (Config.config.https) {
var server = https.createServer(credentials, app);
server.listen(Config.config.port || 8080);
} else {
var server = http.createServer(app);
server.listen(Config.config.port || 8080);
}
console.log(
`listening on port ${Config.config.port || 8080}` + ` with https ${Config.config.https ? 'enabled' : 'disabled'}`
);

View File

@ -1,52 +0,0 @@
const Config = require('./config.js');
const nodemailer = require('nodemailer');
class Mailer {
sender;
started = false;
mailer;
constructor(host, port, email, password) {
this.mailer = nodemailer.createTransport({
host: host,
port: port,
secure: true,
auth: {
user: email,
pass: password
}
});
this.sender = email;
this.started = true;
}
async sendMail(recipients, subject, content, contentStripped) {
console.log(`sending mail to ${recipients}`);
let info = await this.mailer.sendMail({
from: `"Todo App" <${this.sender}>`,
to: Array.isArray(recipients) ? recipients.join(', ') : recipients,
subject: subject,
text: contentStripped,
html: content
});
}
}
if (!global.mailer || !global.mailer.started) {
if (
!Config.config['mail_host'] ||
!Config.config['mail_port'] ||
!Config.config['mail_username'] ||
!Config.config['mail_password']
) {
console.error(`could not create email account as
mail_host, mail_port, mail_username or mail_password is not set.`);
process.exit();
}
global.mailer = new Mailer(
Config.config['mail_host'],
Config.config['mail_port'],
Config.config['mail_username'],
Config.config['mail_password']
);
}
module.exports = global.mailer;

View File

@ -1,141 +0,0 @@
const Sequelize = require('sequelize');
const Config = require('./config.js');
const models = (db) => {
const UnverifiedUser = db.define('UnverifiedUser', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true,
unique: true
},
verificationToken: {
type: Sequelize.DataTypes.STRING,
allowNull: false
},
email: {
type: Sequelize.DataTypes.STRING,
allowNull: false,
unique: true
},
discord_only_account: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
discord_id: {
type: Sequelize.DataTypes.STRING,
allowNull: true
},
password_hash: {
type: Sequelize.DataTypes.STRING,
allowNull: true
}
});
const User = db.define('User', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true,
unique: true
},
email: {
type: Sequelize.DataTypes.STRING,
allowNull: false,
unique: true
},
discord_only_account: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
discord_id: {
type: Sequelize.DataTypes.STRING,
allowNull: true
},
password_hash: {
type: Sequelize.DataTypes.STRING,
allowNull: true
}
});
const Todo = db.define('Todo', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true,
unique: true
},
user: {
type: Sequelize.DataTypes.UUID,
allowNull: false
},
content: {
type: Sequelize.DataTypes.TEXT,
allowNull: false
},
tags: {
type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING),
allowNull: true
},
complete: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
deadline: {
type: Sequelize.DataTypes.DATE,
allowNull: true
}
});
const Grouping = db.define('Grouping', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true,
unique: true
},
complete: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: true
},
manually_added: {
type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.UUID),
allowNull: true
},
required: {
type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING),
allowNull: true
},
exclusions: {
type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING),
allowNull: true
}
});
let options = {
alter: false
};
if (Config.config.alter_db) {
options.alter = true;
}
UnverifiedUser.sync(options);
User.sync(options);
Todo.sync(options);
Grouping.sync(options);
return {
user: User,
unverifiedUser: UnverifiedUser,
todo: Todo,
grouping: Grouping
};
};
module.exports = models;

View File

@ -1,146 +0,0 @@
const express = require('express');
const paginate = require('express-paginate');
const Database = require('./db_interface.js');
const User = require('./user.js');
const { Op } = require('sequelize');
let router = express.Router();
router.use(express.json());
function map_todo(result) {
return {
id: result.id,
content: result.content,
tags: result.tags
};
}
function parse_tags(tags) {
result = {
complete: undefined,
required: [],
excluded: []
};
tags.map((tag) => {
if (tag === 'complete') {
complete = true;
} else if (tag === '~complete') {
complete = false;
} else if (tag.startsWith('~')) {
excluded.push(tag);
} else {
required.push(tag);
}
});
return result;
}
const todo_fields = ['currentPage', 'limit'];
router.use(paginate.middleware(10, 50));
router.use('/todos', User.enforce_session_login);
router.get('/todos', async (req, res) => {
if (!req.query) {
return res.status(400).json({
error: `query must include the fields: ${todo_fields.join(', ')}}`
});
} else {
let error = [];
for (let field of todo_fields) {
if (!req.query[field]) {
error.push(field);
}
}
if (error.length > 0) {
return res.status(400).json({
error: `query must include the fields: ${error.join(', ')}}`
});
}
}
let tag_options = {};
if (req.query.tags) {
let parsed = parse_tags(req.query.tags.split(','));
tag_options['tags'] = {
[Op.and]: parsed.required,
[Op.not]: parsed.excluded
};
if (parsed.complete !== undefined) {
tag_options['complete'] = {
[Op.is]: parsed.complete
};
}
}
console.log(tag_options);
let all_todos = await Database.schemas.todo.findAndCountAll({
where: {
user: req.get('id'),
...tag_options
},
limit: req.query.limit,
offset: req.skip
});
const item_count = all_todos.count;
const page_count = Math.ceil(item_count / req.query.limit);
res.json({
result: all_todos.map(map_todo),
currentPage: req.query.currentPage,
pageCount: page_count,
itemCount: item_count,
pages: paginate.getArrayPages(req)(5, page_count, req.query.currentPage)
});
});
router.use('/todo', User.enforce_session_login);
router.get('/todo/:id([a-f0-9-]+)', async (req, res) => {
let userid = req.get('id');
let id = req.params?.id;
let match = await Database.schemas.todo.findOne({
where: {
user: userid,
id: id
}
});
if (!match) {
return res.sendStatus(404);
}
return res.json({
result: map_todo(match),
tags: get_tags(match.id)
});
});
router.use('/todo', User.enforce_session_login);
router.post('/todo/:id([a-f0-9-]+)', async (req, res) => {
let userid = req.get('id');
let id = req.params?.id;
let body = req.body;
if (!body) {
return res.sendStatus(400);
}
let match = await Database.schemas.todo.findOne({
where: {
user: userid,
id: id
}
});
if (!match) {
return res.sendStatus(404);
}
//
return res.json({
result: map_todo(match),
tags: get_tags(match.id)
});
});
module.exports = {
router: router
};

View File

@ -1,424 +0,0 @@
const express = require('express');
const crypto = require('crypto');
const fetch = require('node-fetch');
const Config = require('./config.js');
const Database = require('./db_interface.js');
const Mail = require('./mail.js');
let router = express.Router();
router.use(express.json());
let session_entropy = {};
user_cache = {};
email_cache = {};
discord_cache = {};
discord_user_cache = {};
async function fetch_user(where) {
let user = await Database.schemas.user.findOne({ where: where });
if (user === null) {
return undefined;
}
user_cache[user.id] = {
id: user.id,
email: user.email,
discord_id: user.discord_id,
password_hash: user.password_hash
};
email_cache[user.email] = user.id;
if (user.discord_id) {
discord_cache[user.discord_id] = user.id
}
return user_cache[user.id]
}
async function fetch_discord_user(auth) {
const result = await fetch(`https://discord.com/api/v8/users/@me`, {
headers: {
'Authorization': auth.token_type + ' ' + auth.access_token
}
});
const json = result.json();
discord_user_cache[json.id] = {
user: json,
auth: auth,
expires: (new Date().getTime()) + (json.expires_in * 1000)
}
return discord_user_cache[id];
}
async function acquire_discord_token(code, redirect) {
let data = {
client_id: Config.config.discord_id,
client_secret: Config.config.discord_secret,
grant_type: 'authorization_code',
code: code,
redirect_uri: redirect
}
const result = await fetch(`https://discord.com/api/oauth2/token`, {
method: 'POST',
body: data,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}).catch(err => console.error(err));
if (!result.ok) {
return res.status(500).json({error: "could not fetch user details"})
}
const json = result.json();
return fetch_discord_user(json);
}
async function refresh_discord_token(id) {
let data = {
client_id: Config.config.discord_id,
client_secret: Config.config.discord_secret,
grant_type: 'refresh_token',
refresh_token: discord_user_cache[id].auth.refresh_token
}
const result = await fetch(`https://discord.com/api/oauth2/token`, {
method: 'POST',
body: data,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}).catch(err => console.error(err));
if (!result.ok) {
return false;
}
const json = result.json();
discord_user_cache[id].auth.access_token = json.access_token;
discord_user_cache[id].expires = (new Date().getTime()) + (json.expires_in * 1000);
return true;
}
async function get_user_details(id) {
if (!id || id === 'undefined') {
return undefined;
}
console.log(`search for user with id ${id}`);
if (!user_cache[id]) {
return await fetch_user({ id: id })
}
// console.log(`returning ${JSON.stringify(user_cache[id])}`);
return user_cache[id];
}
async function get_user_details_by_email(email) {
if (!email || email === 'undefined') {
return undefined;
}
console.log(`search for user with email ${email}}`);
if (!email_cache[email] || !user_cache[email_cache[email]]) {
return await fetch_user({ email: email })
}
// console.log(`returning ${JSON.stringify(user_cache[email_cache[email]])}`);
return user_cache[email_cache[email]];
}
async function get_user_details_by_discord_id(id) {
if (!id || id === 'undefined') {
return undefined;
}
if (!discord_cache[id] || !user_cache[discord_cache[id]]) {
return await fetch_user({ discord_id: id })
}
return user_cache[discord_cache[id]];
}
function hash(secret, password, base64 = true) {
let pw_hash = crypto.pbkdf2Sync(
password,
secret,
Config.config.key?.iterations || 1000,
Config.config.key?.length || 64,
'sha512'
);
return pw_hash.toString(base64 ? 'base64' : 'hex');
}
function verify(secret, password, hash) {
let pw_hash = crypto.pbkdf2Sync(
password,
secret,
Config.config.key?.iterations || 1000,
Config.config.key?.length || 64,
'sha512'
);
return hash === pw_hash.toString('base64');
}
function hash_password(password) {
return hash(Config.config.secret, password);
}
function verify_password(password, hash) {
return verify(Config.config.secret, password, hash);
}
function get_session_token(id, password_hash, base64 = true) {
session_entropy[id] = crypto.randomBytes(Config.config.session_entropy || 32);
return hash(session_entropy[id], password_hash, base64);
}
function verify_session_token(id, hash, token) {
if (session_entropy[id]) {
return verify(session_entropy[id], hash, token);
} else {
return false;
}
}
async function enforce_session_login(req, res, next) {
let userid = req.get('id');
let session_token = req.get('authorization');
console.log('a', userid, session_token);
if (!userid || !session_token) {
return res.sendStatus(401);
}
let user = await get_user_details(userid);
if (!user) {
return res.sendStatus(401);
}
let verified_session = verify_session_token(userid, user.password_hash, session_token);
if (!verified_session) {
return res.sendStatus(401);
}
return next();
}
router.post('/signup', async (req, res) => {
if (!req.body?.email || !req.body?.password) {
return res.status(400).json({
error: 'must have email and password fields'
});
}
let user = await get_user_details_by_email(req.body?.email);
if (user !== undefined && user !== {}) {
console.warn(`user already found: ${JSON.stringify(user)}`);
return res.status(403).json({
error: `email ${req.body.email} is already in use.`
});
} else {
let match = await Database.schemas.unverifiedUser.findOne({ where: { email: req.body.email } });
if (!!match) {
await Database.schemas.unverifiedUser.destroy({ where: { email: match.email } });
}
let randomString = 'Signup';
for (let i = 0; i < 16; i++) {
randomString += Math.floor(Math.random() * 10);
}
let password_hash = hash_password(req.body.password);
let user = await Database.schemas.unverifiedUser.create({
email: String(req.body.email),
password_hash: password_hash,
verificationToken: get_session_token(randomString, password_hash, false)
});
const link = `${Config.config.https ? 'https://' : 'http://'}${req.headers.host}/api/user/verify?verification=${user.verificationToken
}`;
const content = `Click here to verify your sign-up:
${link}`;
const contentHtml = `<h1>Click here to verify your sign-up:</h1>
<p><a href=${link}>${link}</a></p>`;
await Mail.sendMail([String(req.body.email)], 'Verify Your Account', contentHtml, content);
return res.sendStatus(204);
}
});
router.get('/verify', async (req, res) => {
if (!req.query?.verification) {
return res.status(400).send(
`<html>
<body>
<h1>No Verification Link</h1>
</body>
</html>`
);
}
let verification = req.query?.verification;
let user = await Database.schemas.unverifiedUser.findOne({ where: { verificationToken: verification } });
if (user !== undefined && user !== {}) {
if (user.verificationToken != verification) {
return res.status(404).send(
`<html>
<body>
<h1>Unknown Verification Link</h1>
</body>
</html>`
);
}
let newUser = await Database.schemas.user.create({
email: user.email,
password_hash: user.password_hash
});
return res.send(`<html>
<body>
<h1>Sign up complete.</h1>
</body>
</html>`);
} else {
return res.status(404).send(`<html>
<body>
<h1>Unknown Verification Link</h1>
</body>
</html>`);
}
});
router.get('/login/discord', async (req, res) => {
if (!Config.config.discord_id || !Config.config.discord_secret) {
return res.status(403).send("discord login is not enabled.");
}
const url = encodeURIComponent(`${req.headers.host}discord`);
return res.send(`https://discord.com/api/oauth2/authorize?client_id=${Config.config.discord_id}&redirect_uri=${url}&response_type=code&scope=identify%20email%20guilds`);
});
router.post('/login/discord', async (req, res) => {
if (!Config.config.discord_id || !Config.config.discord_secret) {
return res.status(403).json({ error: "discord login is not enabled." });
}
if (!req.params.code || !req.headers.host) {
return res.status(400).json({error: "invalid oauth request"});
}
const result = await acquire_discord_token(req.params.code, req.headers.host);
const matching_account = await get_user_details_by_discord_id(result.user.id);
if (!matching_account) {
let user = await Database.schemas.unverifiedUser.create({
email: String(result.user.email),
discord_id: user.id,
verificationToken: get_session_token(randomString, result.auth.access_token, false)
});
return res.json({
type: 'unverified',
verificationToken: user.verificationToken
})
}
return res.json({
type: 'verified',
userid: matching_account.id,
session_token: get_session_token(matching_account.id, result.auth.access_token)
});
});
//TODO
router.post('/discord/create', async (req, res) =>{});
router.post('/discord/link', async (req, res) =>{});
router.post('/login', async (req, res) => {
if (!req.body?.email || !req.body?.password) {
return res.status(400).json({
error: 'must have email and password fields'
});
}
let user = await get_user_details_by_email(req.body.email);
if (!user) {
return res.status(401).json({
error: 'incorrect email or password'
});
}
let verified = verify_password(req.body.password, user.password_hash);
if (!verified) {
return res.status(401).json({
error: 'incorrect email or password'
});
}
return res.json({
userid: user.id,
session_token: get_session_token(user.id, user.password_hash)
});
});
router.use('/logout', enforce_session_login)
router.post('/logout', async (req, res) => {
let userid = req.get('id');
let session_token = req.get('authorization');
let user = await get_user_details(userid);
if (!user) {
return res.status(401).json({
error: 'invalid user data'
});
}
let verified = verify_session_token(user.id, user.password_hash, session_token);
if (!verified) {
return res.status(401).json({
error: 'invalid user data'
});
}
delete session_entropy[user.id];
return res.sendStatus(204);
});
router.use('/byEmail', enforce_session_login)
router.get('/byEmail/:email', async (req, res) => {
if (!req.params?.email) {
res.status(400).json({
error: 'email is a required parameter'
});
}
let user = get_user_details_by_email(req.params.email);
console.log(user);
if (user !== undefined && user !== {}) {
res.json({
id: user.id,
email: user.email
});
} else {
res.sendStatus(404);
}
});
router.use('/', enforce_session_login)
router.get('/:id([a-f0-9-]+)', async (req, res) => {
console.log(req.params);
if (!req.params?.id) {
return res.status(400).json({
error: 'must have id parameter'
});
}
let id = req.params?.id;
console.log(id);
let user = await get_user_details(id);
console.log(user);
if (user !== undefined && user !== {}) {
return res.json({
id: user.id,
email: user.email
});
} else {
return res.sendStatus(404);
}
});
router.use('/authorized', enforce_session_login);
router.get('/authorized', async (req, res) => {
let userid = req.get('id');
let user = await get_user_details(userid);
return res.json({
authorized: true,
user: {
id: user.id,
email: user.email
}
});
});
module.exports = {
router: router,
enforce_session_login: enforce_session_login,
get_user_details: get_user_details,
get_user_details_by_email: get_user_details_by_email
};

View File

@ -1,17 +0,0 @@
const { expect } = require('chai');
const Models = require('../src/models');
const { sequelize, checkModelName, checkUniqueIndex, checkPropertyExists } = require('sequelize-test-helpers');
describe('Sequelize model tests', function () {
const models = Models(sequelize);
checkModelName(models.user)('User');
checkModelName(models.unverifiedUser)('UnverifiedUser');
checkModelName(models.grouping)('Grouping');
checkModelName(models.todo)('Todo');
context('user props', function () {
['id', 'email', 'discord_only_account'].forEach(checkPropertyExists(new models.user()));
});
});

View File

@ -1,10 +0,0 @@
const { expect } = require('chai');
const proxyrequire = require('proxyrequire');
const { match, stub, resetHistory } = require('sinon');
const { sequelize, Sequelize, makeMockModels } = require('sequelize-test-helpers');
describe('User Router Tests', function () {
const Database = proxyrequire('../src/db_interface', {
sequelize: Sequelize
});
});

0
default.profraw Normal file
View File

5
diesel.toml Normal file
View File

@ -0,0 +1,5 @@
# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"

26
env.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
args=("$@")
if [[ ${1} == "prod" ]]; then
echo "prod build"
export RUSTFLAGS=""
ARGS="--release"
if [[ ${2} ]]; then
cargo ${2} $ARGS ${args[@]:2}
else
echo "defaulting to build"
cargo build $ARGS
fi
else
echo "dev build"
export RUSTFLAGS="-Zinstrument-coverage -Zmacro-backtrace"
ARGS=""
if [[ ${1} ]]; then
cargo ${1} $ARGS ${args[@]:1}
else
echo "defaulting to build+tests"
cargo test
grcov . --binary-path ./target/debug -s . -t html --branch --ignore-not-existing -o ./test_coverage/
fi
fi

23
frontend/.gitignore vendored
View File

@ -1,23 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -1,16 +0,0 @@
arrowParens: 'always'
bracketSpacing: true
endOfLine: 'lf'
htmlWhitespaceSensitivity: 'css'
insertPragma: false
jsxBracketSameLine: true
jsxSingleQuote: true
printWidth: 120
proseWrap: 'preserve'
quoteProps: 'consistent'
requirePragma: false
semi: true
singleQuote: true
tabWidth: 2
trailingComma: 'none'
useTabs: false

View File

@ -1,70 +0,0 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `yarn build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

View File

@ -1,50 +0,0 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0",
"@material-ui/core": "^5.0.0-beta.0",
"@material-ui/icons": "^4.11.2",
"@material-ui/styles": "^4.11.4",
"@reduxjs/toolkit": "^1.6.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^11.2.7",
"@testing-library/user-event": "^12.8.3",
"axios": "^0.21.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.4",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"web-vitals": "^1.1.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@ -1,13 +0,0 @@
{
"hosts": {
"localhost:3000": "LOCAL",
"dev.j4.pm": "test",
"todo.j4.pm": "prod"
},
"defaultConfig": {},
"configs": {
"LOCAL": {},
"test": {"apiUrl": "https://dev.j4.pm/api"},
"prod": {"apiUrl": "https://todo.j4.pm/api"}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -1,82 +0,0 @@
import { BrowserRouter as Router, Switch, Route, Redirect } from 'react-router-dom';
import RootPage from './modules/Root';
import AboutPage from './modules/About';
import AccountPage from './modules/Account';
import LoginPage from './modules/Login';
import OauthPage from './modules/Oauth';
import SignupPage from './modules/Signup';
import TodoPage from './modules/TodoList';
import { connect } from 'react-redux';
import ThemeProvider from './ThemeProvider';
import Navbar from './Navbar';
const App = (props) => {
return (
<ThemeProvider>
<Navbar />
<Router>
<Switch>
<Route path='/about'>
<AboutPage />
</Route>
<Route path='/login'>
<LoginPage />
</Route>
<Route path='/signup'>
<SignupPage />
</Route>
<Route path='/discord'>
<OauthPage />
</Route>
<PRoute path='/todos'>
<TodoPage />
</PRoute>
<PRoute path='/account'>
<AccountPage />
</PRoute>
<PRoute exact path='/'>
<RootPage />
</PRoute>
<Route path='/'>
<Redirect
to={{
pathname: '/'
}}
/>
</Route>
</Switch>
</Router>
</ThemeProvider>
);
};
const PRoute = connect(
(state) => {
return {
token: state.login.token
};
},
(dispatch, props) => {
return {};
}
)(({ children, ...props }) => {
return (
<Route
{...props}
render={({ location }) => {
return props.token !== undefined ? (
children
) : (
<Redirect
to={{
pathname: '/login',
state: { from: location }
}}
/>
);
}}
/>
);
});
export default App;

View File

@ -1,122 +0,0 @@
import Grid from '@material-ui/core/Grid';
import GroupIcon from '@material-ui/icons/Group';
import SettingsIcon from '@material-ui/icons/Settings';
import AssignmentIndIcon from '@material-ui/icons/AssignmentInd';
import Box from '@material-ui/core/Box';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/styles';
import { logout } from './reducers/login';
const styles = (theme) => {
return {
container: {
width: '100%'
},
flexbox: {
display: 'flex',
flexGrow: 1,
width: 'auto'
},
buttonWrapper: {
alignItems: 'flex-end'
},
button: {
alignItems: 'flex-end'
},
typography: {
margin: '5px 0px'
}
};
};
const Navbar = (props) => {
const { classes } = props;
return (
<div>
<Grid container direction='row' alignItems='flex-end' justify='flex-end' className={classes.container}>
<Grid item className={classes.flexbox}>
<Box className={classes.flexbox} />
</Grid>
<Grid item alignItems='flex-end' justify='flex-end'>
<LoginLogoutButton className={classes.buttonWrapper} {...props} />
</Grid>
</Grid>
</div>
);
};
const LoginLogoutButton = connect(
(state) => {
return {
token: state.login.token
};
},
(dispatch, props) => {
return {};
}
)(({ children, ...props }) => {
const { classes } = props;
return props.token !== undefined ? (
<>
<Button
variant='contained'
color='primary'
onClick={() => {
window.location.pathname = '/account';
}}
className={classes.button}>
<SettingsIcon />
<Typography>Settings</Typography>
</Button>
<Button
variant='contained'
color='primary'
onClick={() => {
props.logout();
}}
className={classes.button}>
<ExitToAppIcon />
<Typography className={classes.typography}>Log Out</Typography>
</Button>
</>
) : (
<>
<Button
variant='contained'
color='primary'
onClick={() => {
window.location.pathname = '/login';
}}
className={classes.button}>
<GroupIcon />
<Typography className={classes.typography}>Log In</Typography>
</Button>
<Button
variant='contained'
color='primary'
onClick={() => {
window.location.pathname = '/signup';
}}
className={classes.button}>
<AssignmentIndIcon />
<Typography className={classes.typography}>Sign Up</Typography>
</Button>
</>
);
});
export default connect(
(state, props) => {
return {};
},
(dispatch, props) => {
return {
logout: () => {
dispatch(logout());
}
};
}
)(withStyles(styles)(Navbar));

View File

@ -1,37 +0,0 @@
import CssBaseline from '@material-ui/core/CssBaseline';
import { withStyles, withTheme } from '@material-ui/styles';
import { createTheme, ThemeProvider } from '@material-ui/core/styles';
import theme from './theme';
const styles = () => {
return {
root: {
height: '100vh',
zIndex: 1,
overflow: 'hidden',
position: 'relative',
display: 'flex'
},
content: {
zIndex: 3,
flexGrow: 1
},
spacing: (n) => {
return `${n * 2}px`;
}
};
};
const ThemeWrapper = (props) => {
const { children, classes } = props;
return (
<ThemeProvider theme={createTheme(theme)}>
<CssBaseline />
<div id='main' className={classes.content}>
{children}
</div>
</ThemeProvider>
);
};
export default withTheme(withStyles(styles)(ThemeWrapper));

View File

@ -1,79 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { configureStore } from '@reduxjs/toolkit';
import RootReducer from './reducers';
import axios from 'axios';
import logger from 'redux-logger';
const defaultConfig = {
apiUrl: 'http://localhost:8080/api'
};
const renderApp = ({ config, user }) => {
const isDev = process.env.NODE_ENV !== 'production';
const store = configureStore({
devTools: isDev,
preloadedState: {
config: config,
login: user
},
reducer: RootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger)
});
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
};
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
const findConfig = (fullConfig) => {
return Object.assign(fullConfig.defaultConfig, fullConfig.configs[fullConfig.hosts[window.location.host]]);
};
axios
.get('/config.json')
.then(
(success) => {
return Object.assign(defaultConfig, findConfig(success.data));
},
() => {
return defaultConfig;
}
)
.then((config) => {
const details = JSON.parse(localStorage.getItem('userDetails') || '{}');
return axios
.get(`${config.apiUrl}/user/authorized`, {
headers: {
id: details.id,
Authorization: details.token
}
})
.then(
(success) => {
return {
config,
user: details || {}
};
},
() => {
return {
config,
user: {}
};
}
);
})
.then(({ config, user }) => {
renderApp({ config, user });
});

View File

@ -1,5 +0,0 @@
const AboutPage = (props) => {
return <div></div>;
};
export default AboutPage;

View File

@ -1,5 +0,0 @@
const AccountPage = (props) => {
return <div></div>;
};
export default AccountPage;

View File

@ -1,174 +0,0 @@
import { login, forgotPassword } from '../../reducers/login';
import { useState } from 'react';
import Button from '@material-ui/core/Button';
import Grid from '@material-ui/core/Grid';
import InputAdornment from '@material-ui/core/InputAdornment';
import IconButton from '@material-ui/core/IconButton';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
import Visibility from '@material-ui/icons/Visibility';
import VisibilityOff from '@material-ui/icons/VisibilityOff';
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/styles';
const styles = (theme) => { };
const LoginPage = (props) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [visible, setVisible] = useState(false);
const [error, setError] = useState(false);
const [forgotPassword, setForgotPassword] = useState(false);
const handleForgotPassword = () => {
if (!!email) {
props.forgotPassword(email);
setForgotPassword(false);
setError(false);
setEmail('');
} else {
setError(true);
}
};
return (
<Grid container alignItems='center' justify='center'>
<Grid item>
<form>
<div>
<Typography align='center' variant='h6' gutterBottom>
{forgotPassword ? 'Reset Password' : 'Sign in'}
</Typography>
<TextField
autoComplete='current-email'
error={error}
label='Email'
name='email'
type='email'
value={email}
variant='outlined'
onChange={(event) => {
return setEmail(event.target.value ?? '');
}}
onKeyPress={(event) => {
if (event.key === 'Enter') {
forgotPassword ? props.forgotPassword(email) : props.login(email, password);
}
}}
/>
{forgotPassword === false ? (
<>
<div>
<TextField
autoComplete='current-password'
label='Password'
name='password'
type={visible ? 'text' : 'password'}
value={password}
variant='outlined'
onChange={(event) => {
setPassword(event.target.value ?? '');
}}
onKeyPress={(event) => {
if (event.key === 'Enter') {
props.login(email, password);
}
}}
InputProps={{
endAdornment: (
<InputAdornment position='end'>
<IconButton
aria-label='toggle password visibility'
onClick={() => {
return setVisible(!visible);
}}
edge='end'>
{visible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<div>
<Button
id='forgot-password-toggle-button'
color='secondary'
onClick={() => {
return setForgotPassword(true);
}}
size='small'>
Forgot Password?
</Button>
</div>
</div>
<Button
id='login-button'
variant='contained'
color='primary'
onClick={() => {
props.login(email, password);
}}>
Login
</Button>
</>
) : (
<Grid container>
<Grid item>
<Button
id='forgot-password-cancel-button'
variant='contained'
fullWidth
color='primary'
onClick={() => {
setForgotPassword(false);
setError(false);
}}>
Cancel
</Button>
</Grid>
<Grid item>
<Button
id='forgot-password-submit-button'
variant='contained'
color='secondary'
fullWidth
onClick={() => {
handleForgotPassword();
}}>
Continue
</Button>
</Grid>
<Grid item>
<Button
id='login-with-discord'
variant='contained'
fullWidth
color='secondary'
onClick={()=>{
}}
>Sign in with Discord</Button>
</Grid>
</Grid>
)}
</div>
</form>
</Grid>
</Grid>
);
};
export default connect(
(state) => {
return {};
},
(dispatch, props) => {
return {
login: (email, password) => {
dispatch(login(email, password));
},
forgotPassword: (email) => {
dispatch(forgotPassword(email));
}
};
}
)(withStyles(styles)(LoginPage));

View File

@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/styles';
const styles = (theme) => { };
const OauthPage = (props) => {
return <div>test</div>
}
export default connect(
(state) => {
return {};
},
(dispatch, props) => {
return {};
}
)(withStyles(styles)(OauthPage));

View File

@ -1,5 +0,0 @@
const RootPage = (props) => {
return <div></div>;
};
export default RootPage;

View File

@ -1,162 +0,0 @@
import { signup } from '../../reducers/login';
import { useState } from 'react';
import Button from '@material-ui/core/Button';
import Grid from '@material-ui/core/Grid';
import InputAdornment from '@material-ui/core/InputAdornment';
import IconButton from '@material-ui/core/IconButton';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
import Visibility from '@material-ui/icons/Visibility';
import VisibilityOff from '@material-ui/icons/VisibilityOff';
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/styles';
const styles = (theme) => {};
const SignupPage = (props) => {
//const { classes } = props;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [visible, setVisible] = useState(false);
const [error, setError] = useState(false);
const checkSignup = () => {
if (password !== confirmPassword) {
setError(true);
} else {
setError(false);
}
};
return (
<Grid container alignItems='center' justify='center'>
<Grid item alignItems='center' justify='center'>
<form>
<div>
<Typography align='center' variant='h6' gutterBottom>
Sign Up
</Typography>
<TextField
autoComplete='new-email'
label='Email'
name='email'
type='email'
value={email}
variant='outlined'
fullWidth
onChange={(event) => {
return setEmail(event.target.value ?? '');
}}
onKeyPress={(event) => {
if (event.key === 'Enter') {
checkSignup();
if (!error) {
props.signup(email, password);
}
}
}}
/>
<div>
<TextField
autoComplete='new-password'
label='Password'
name='password'
type={visible ? 'text' : 'password'}
value={password}
variant='outlined'
fullWidth
onChange={(event) => {
setPassword(event.target.value ?? '');
}}
onKeyPress={(event) => {
if (event.key === 'Enter') {
checkSignup();
if (!error) {
props.signup(email, password);
}
}
}}
InputProps={{
endAdornment: (
<InputAdornment position='end'>
<IconButton
aria-label='toggle password visibility'
onClick={() => {
return setVisible(!visible);
}}
edge='end'>
{visible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
</div>
<div>
<TextField
autoComplete='confirm-password'
label='Confirm Password'
name='confirm-password'
type={visible ? 'text' : 'password'}
error={error}
value={confirmPassword}
variant='outlined'
fullWidth
onChange={(event) => {
setConfirmPassword(event.target.value ?? '');
}}
onKeyPress={(event) => {
checkSignup();
if (event.key === 'Enter') {
if (!error) {
props.signup(email, password);
}
}
}}
InputProps={{
endAdornment: (
<InputAdornment position='end'>
<IconButton
aria-label='toggle confirm password visibility'
onClick={() => {
return setVisible(!visible);
}}
edge='end'>
{visible ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
</div>
<Button
id='login-button'
variant='contained'
color='primary'
onClick={() => {
checkSignup();
if (!error) {
props.signup(email, password);
}
}}>
Sign Up
</Button>
</div>
</form>
</Grid>
</Grid>
);
};
export default connect(
(state) => {
return {};
},
(dispatch, props) => {
return {
signup: (email, password) => {
dispatch(signup(email, password));
}
};
}
)(withStyles(styles)(SignupPage));

View File

@ -1,5 +0,0 @@
const TodoPage = (props) => {
return <div></div>;
};
export default TodoPage;

View File

@ -1,28 +0,0 @@
import { createAction, createAsyncAction } from './utils';
import { createReducer } from '@reduxjs/toolkit';
const actions = {
refresh: 'LOCAL_STORAGE_REFRESH'
};
export const getConfigValue = createAsyncAction((dispatch, getState, config, key) => {
const payload = {
key: key,
value: JSON.parse(localStorage.getItem(key)) || undefined
};
return dispatch(refreshConfigValue(payload));
});
export const setConfigValue = createAsyncAction((dispatch, getState, config, key, value) => {
localStorage.setItem(key, JSON.stringify(value));
return dispatch(refreshConfigValue({ key: key, value: value }));
});
export const refreshConfigValue = createAction(actions.refresh, (payload) => {
return payload;
});
export default createReducer({}, (builder) => {
builder.addDefaultCase((state, action) => {
state[action.payload?.key] = action.payload?.value;
});
});

View File

@ -1,10 +0,0 @@
import { combineReducers } from '@reduxjs/toolkit';
import LocalStorageReducer from './localStorage';
import LoginReducer from './login';
import ConfigReducer from './config';
export default combineReducers({
localStorage: LocalStorageReducer,
login: LoginReducer,
config: ConfigReducer
});

View File

@ -1,28 +0,0 @@
import { createAction, createAsyncAction } from './utils';
import { createReducer } from '@reduxjs/toolkit';
const actions = {
refresh: 'LOCAL_STORAGE_REFRESH'
};
export const readLocalStorage = createAsyncAction((dispatch, getState, config, key) => {
const payload = {
key: key,
value: JSON.parse(localStorage.getItem(key)) || undefined
};
return dispatch(refreshLocalStorage(payload));
});
export const updateLocalStorage = createAsyncAction((dispatch, getState, config, key, value) => {
localStorage.setItem(key, JSON.stringify(value));
return dispatch(refreshLocalStorage({ key: key, value: value }));
});
export const refreshLocalStorage = createAction(actions.refresh, (payload) => {
return payload;
});
export default createReducer({}, (builder) => {
builder.addDefaultCase((state, action) => {
state[action.payload?.key] = action.payload?.value;
});
});

View File

@ -1,120 +0,0 @@
import axios from 'axios';
import { createAction, createAsyncAction } from './utils';
import { createReducer } from '@reduxjs/toolkit';
import { updateLocalStorage } from './localStorage';
const actions = {
update: 'UPDATE_LOGIN_DETAILS'
};
const updateLoginDetails = createAction(actions.update, (payload) => {
return payload;
});
export const login = createAsyncAction((dispatch, getState, config, email, password) => {
axios
.post(`${config.apiUrl}/user/login`, {
email: email,
password: password
})
.then(
(success) => {
console.error('success', success);
dispatch(
updateLoginDetails({
id: success.data['userid'],
token: success.data['session_token'],
error: false
})
);
dispatch(
updateLocalStorage('userDetails', {
id: success.data['userid'],
token: success.data['session_token']
})
);
window.location.pathname = '/';
},
(reject) => {
console.error(reject);
dispatch(
updateLoginDetails({
id: undefined,
token: undefined,
error: true
})
);
dispatch(
updateLocalStorage('userDetails', {
id: undefined,
token: undefined
})
);
}
);
});
export const signup = createAsyncAction((dispatch, getState, config, email, password) => {
axios
.post(`${config.apiUrl}/user/signup`, {
email: email,
password: password
})
.then(
(success) => {
console.error('success', success);
window.location.pathname = '/login';
},
(reject) => {
console.error(reject);
}
);
});
export const forgotPassword = createAsyncAction((dispatch, getState, config, email) => {});
export const logout = createAsyncAction((dispatch, getState, config) => {
const details = getState().login;
axios
.post(`${config.apiUrl}/user/logout`, {
userid: details.id,
session_token: details.token
})
.then(
(success) => {
dispatch(
updateLoginDetails({
id: undefined,
token: undefined,
error: false
})
);
dispatch(
updateLocalStorage('userDetails', {
id: undefined,
token: undefined
})
);
window.location.pathname = '/login';
},
(reject) => {
console.warn(reject);
console.warn('could not log out.');
}
);
});
export default createReducer(
{
id: undefined,
token: undefined,
error: false
},
(builder) => {
builder.addCase(actions.update, (state, action) => {
console.error(state, action);
state = { ...state, ...action?.payload };
});
}
);

View File

@ -1,16 +0,0 @@
export const createAction = (type, payload) => {
return (...args) => {
return {
type: type,
payload: payload(...args)
};
};
};
export const createAsyncAction = (payload) => {
return (...args) => {
return function (dispatch, getState, config) {
payload(dispatch, getState, getState()?.config, ...args);
};
};
};

View File

@ -1,13 +0,0 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -1,13 +0,0 @@
import grey from '@material-ui/core/colors/grey';
const theme = {
palette: {
primary: {
main: grey[200]
},
secondary: {
main: grey[200]
}
}
};
export default theme;

0
migrations/.gitkeep Normal file
View File

View File

@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View File

@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE IF EXISTS users;

View File

@ -0,0 +1,9 @@
-- Your SQL goes here
CREATE TABLE IF NOT EXISTS users (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
discord_id VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
);
SELECT diesel_manage_updated_at('users');

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE IF EXISTS blocks;

View File

@ -0,0 +1,12 @@
-- Your SQL goes here
CREATE TABLE IF NOT EXISTS blocks (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL,
block_type VARCHAR(255) NOT NULL,
props JSON NOT NULL,
children TEXT[],
created_at TIMESTAMP DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP DEFAULT NOW() NOT NULL
);
SELECT diesel_manage_updated_at('blocks');

15
src/endpoints.rs Normal file
View File

@ -0,0 +1,15 @@
use axum::{prelude::*, routing::BoxRoute};
pub mod block;
pub mod discord;
pub mod user;
async fn hello() -> &'static str {
"Hi"
}
pub fn get_routes() -> BoxRoute<Body> {
route("/api/hello", any(hello))
.nest("/api/auth", discord::get_routes())
.boxed()
}

0
src/endpoints/block.rs Normal file
View File

222
src/endpoints/discord.rs Normal file
View File

@ -0,0 +1,222 @@
use async_redis_session::RedisSessionStore;
use async_session::{Session, SessionStore};
use axum::{
async_trait,
extract::{Extension, FromRequest, Query, RequestParts, TypedHeader},
prelude::*,
response::IntoResponse,
routing::BoxRoute,
AddExtensionLayer,
};
use http::{header::SET_COOKIE, StatusCode};
use hyper::Body;
use oauth2::{
basic::BasicClient, reqwest::async_http_client, AuthUrl, AuthorizationCode, ClientId,
ClientSecret, CsrfToken, RedirectUrl, Scope, TokenResponse, TokenUrl,
};
use serde::{Deserialize, Serialize};
use std::env;
static COOKIE_NAME: &str = "SESSION";
pub fn oauth_client() -> BasicClient {
// Environment variables (* = required):
// *"CLIENT_ID" "123456789123456789";
// *"CLIENT_SECRET" "rAn60Mch4ra-CTErsSf-r04utHcLienT";
// "REDIRECT_URL" "http://127.0.0.1:3000/auth/authorized";
// "AUTH_URL" "https://discord.com/api/oauth2/authorize?response_type=code";
// "TOKEN_URL" "https://discord.com/api/oauth2/token";
let client_id = env::var("CLIENT_ID").expect("Missing CLIENT_ID!");
let client_secret = env::var("CLIENT_SECRET").expect("Missing CLIENT_SECRET!");
let redirect_url = env::var("REDIRECT_URL")
.unwrap_or_else(|_| "http://127.0.0.1:3000/auth/authorized".to_string());
let auth_url = env::var("AUTH_URL").unwrap_or_else(|_| {
"https://discord.com/api/oauth2/authorize?response_type=code".to_string()
});
let token_url = env::var("TOKEN_URL")
.unwrap_or_else(|_| "https://discord.com/api/oauth2/token".to_string());
let client = BasicClient::new(
ClientId::new(client_id),
Some(ClientSecret::new(client_secret)),
AuthUrl::new(auth_url).unwrap(),
Some(TokenUrl::new(token_url).unwrap()),
)
.set_redirect_uri(RedirectUrl::new(redirect_url).unwrap());
tracing::debug!("client: {:?}", client);
client
}
// The user data we'll get back from Discord.
// https://discord.com/developers/docs/resources/user#user-object-user-structure
#[derive(Debug, Serialize, Deserialize)]
struct DiscordUser {
id: String,
avatar: Option<String>,
username: String,
discriminator: String,
}
// Session is optional
async fn index(user: Option<DiscordUser>) -> impl IntoResponse {
match user {
Some(u) => format!(
"Hey {}! You're logged in!\nYou may now access `/protected`.\nLog out with `/logout`.",
u.username
),
None => "You're not logged in.\nVisit `/auth/discord` to do so.".to_string(),
}
}
async fn discord_auth(Extension(client): Extension<BasicClient>) -> impl IntoResponse {
let (auth_url, _csrf_token) = client
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("identify".to_string()))
.url();
// Redirect to Discord's oauth service
Redirect(auth_url.into())
}
// Valid user session required. If there is none, redirect to the auth page
async fn protected(user: DiscordUser) -> impl IntoResponse {
serde_json::to_string(&user).expect("could not serialize user")
}
async fn avatar_url(user: DiscordUser) -> impl IntoResponse {
let cdn_url = env::var("CDN_URL").unwrap_or_else(|_| "https://cdn.discordapp.com".to_string());
match user.avatar {
Some(id) => format!("{}/avatars/{}/{}.webp?size=256", cdn_url, user.id, id),
None => format!("{}/embed/avatars/0.png?size=256", cdn_url),
}
}
async fn logout(
Extension(store): Extension<RedisSessionStore>,
TypedHeader(cookies): TypedHeader<headers::Cookie>,
) -> impl IntoResponse {
let cookie = cookies.get(COOKIE_NAME).unwrap();
let session = match store.load_session(cookie.to_string()).await.unwrap() {
Some(s) => s,
// No session active, just redirect
None => return Redirect("/".to_string()),
};
store.destroy_session(session).await.unwrap();
Redirect("/".to_string())
}
#[derive(Debug, Deserialize)]
struct AuthRequest {
code: String,
state: String,
}
async fn login_authorized(
Query(query): Query<AuthRequest>,
Extension(store): Extension<RedisSessionStore>,
Extension(oauth_client): Extension<BasicClient>,
) -> impl IntoResponse {
// Get an auth token
let token = oauth_client
.exchange_code(AuthorizationCode::new(query.code.clone()))
.request_async(async_http_client)
.await
.unwrap();
// Fetch user data from discord
let client = reqwest::Client::new();
let user_data: DiscordUser = client
// https://discord.com/developers/docs/resources/user#get-current-user
.get("https://discordapp.com/api/users/@me")
.bearer_auth(token.access_token().secret())
.send()
.await
.unwrap()
.json::<DiscordUser>()
.await
.unwrap();
// Create a new session filled with user data
let mut session = Session::new();
session.insert("user", &user_data).unwrap();
// Store session and get corresponding cookie
let cookie = store.store_session(session).await.unwrap().unwrap();
// Build the cookie
let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie);
// Set cookie
let r = http::Response::builder()
.header("Location", "/")
.header(SET_COOKIE, cookie)
.status(302);
r.body(Body::empty()).unwrap()
}
// Utility to save some lines of code
struct Redirect(String);
impl IntoResponse for Redirect {
fn into_response(self) -> http::Response<Body> {
let builder = http::Response::builder()
.header("Location", self.0)
.status(StatusCode::FOUND);
builder.body(Body::empty()).unwrap()
}
}
struct AuthRedirect;
impl IntoResponse for AuthRedirect {
fn into_response(self) -> http::Response<Body> {
Redirect("/auth/discord".to_string()).into_response()
}
}
#[async_trait]
impl<B> FromRequest<B> for DiscordUser
where
B: Send,
{
// If anything goes wrong or no session is found, redirect to the auth page
type Rejection = AuthRedirect;
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
let extract::Extension(store) = extract::Extension::<RedisSessionStore>::from_request(req)
.await
.expect("`RedisSessionStore` extension is missing");
let cookies: TypedHeader<headers::Cookie> =
TypedHeader::<headers::Cookie>::from_request(req)
.await
.expect("could not get cookies");
let session_cookie = cookies.0.get(COOKIE_NAME).ok_or(AuthRedirect)?;
let session = store
.load_session(session_cookie.to_string())
.await
.unwrap()
.ok_or(AuthRedirect)?;
let user = session.get::<DiscordUser>("user").ok_or(AuthRedirect)?;
Ok(user)
}
}
pub fn get_routes() -> BoxRoute<Body> {
route("/", get(index))
.route("/discord", get(discord_auth))
.route("/authorized", get(login_authorized))
.route("/protected", get(protected))
.route("/avatar", get(avatar_url))
.route("/logout", get(logout))
.layer(AddExtensionLayer::new(oauth_client()))
.boxed()
}

1
src/endpoints/user.rs Normal file
View File

@ -0,0 +1 @@

2
src/helpers.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod block;
pub mod user;

57
src/helpers/block.rs Normal file
View File

@ -0,0 +1,57 @@
use crate::diesel::{prelude::*, QueryDsl};
use crate::models::*;
use crate::schema::*;
use diesel::r2d2::{ConnectionManager, Pool};
use diesel_tracing::pg::InstrumentedPgConnection;
use std::error::Error;
use tokio_diesel::*;
use uuid::Uuid;
pub async fn create_block(
pool: &Pool<ConnectionManager<InstrumentedPgConnection>>,
block: InsertableBlock,
) -> Result<Block, Box<dyn Error>> {
let inserted: Block = diesel::insert_into(blocks::table)
.values(block)
.get_result_async(pool)
.await?;
Ok(inserted)
}
pub async fn update_block(
pool: &Pool<ConnectionManager<InstrumentedPgConnection>>,
block: Block,
) -> Result<Block, Box<dyn Error>> {
use crate::schema::blocks::dsl::*;
let result = diesel::update(blocks.filter(id.eq(block.id)))
.set((
block_type.eq(block.block_type),
children.eq(block.children),
props.eq(block.props),
user_id.eq(block.user_id),
))
.get_result_async(pool)
.await?;
Ok(result)
}
pub async fn find_block_by_id(
pool: &Pool<ConnectionManager<InstrumentedPgConnection>>,
block_id: Uuid,
) -> Result<Block, Box<dyn Error>> {
use crate::schema::blocks::dsl::*;
let result: Block = blocks.find(block_id).get_result_async(pool).await?;
log::error!("{:?}", result);
Ok(result)
}
pub async fn delete_block_by_id(
pool: &Pool<ConnectionManager<InstrumentedPgConnection>>,
block_id: Uuid,
) -> Result<Block, Box<dyn Error>> {
use crate::schema::blocks::dsl::*;
let result: Block = diesel::delete(blocks.filter(id.eq(block_id)))
.get_result_async(pool)
.await?;
Ok(result)
}

52
src/helpers/user.rs Normal file
View File

@ -0,0 +1,52 @@
use crate::diesel::{prelude::*, QueryDsl};
use crate::models::*;
use crate::schema::*;
use diesel::r2d2::{ConnectionManager, Pool};
use diesel_tracing::pg::InstrumentedPgConnection;
use std::error::Error;
use tokio_diesel::*;
use uuid::Uuid;
pub async fn create_user(
pool: &Pool<ConnectionManager<InstrumentedPgConnection>>,
user: InsertableUser,
) -> Result<User, Box<dyn Error>> {
let inserted: User = diesel::insert_into(users::table)
.values(user)
.get_result_async(pool)
.await?;
Ok(inserted)
}
pub async fn update_user(
pool: &Pool<ConnectionManager<InstrumentedPgConnection>>,
user: User,
) -> Result<User, Box<dyn Error>> {
use crate::schema::users::dsl::*;
let result = diesel::update(users.filter(id.eq(user.id)))
.set((discord_id.eq(user.discord_id),))
.get_result_async(pool)
.await?;
Ok(result)
}
pub async fn find_user_by_id(
pool: &Pool<ConnectionManager<InstrumentedPgConnection>>,
user_id: Uuid,
) -> Result<User, Box<dyn Error>> {
use crate::schema::users::dsl::*;
let result = users.find(user_id).get_result_async::<User>(pool).await?;
log::error!("{:?}", result);
Ok(result)
}
pub async fn delete_user_by_id(
pool: &Pool<ConnectionManager<InstrumentedPgConnection>>,
user_id: Uuid,
) -> Result<User, Box<dyn Error>> {
use crate::schema::users::dsl::*;
let result = diesel::delete(users.filter(id.eq(user_id)))
.get_result_async(pool)
.await?;
Ok(result)
}

88
src/main.rs Normal file
View File

@ -0,0 +1,88 @@
use axum::prelude::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use async_redis_session::RedisSessionStore;
use axum::AddExtensionLayer;
use diesel::r2d2::{ConnectionManager, Pool};
use diesel_tracing::pg::InstrumentedPgConnection;
use dotenv::dotenv;
use std::env;
use std::str::FromStr;
use tower_http::trace::TraceLayer;
#[macro_use]
extern crate diesel;
extern crate redis;
mod endpoints;
pub mod helpers;
pub mod migration;
pub mod models;
pub mod schema;
pub mod tests;
#[tokio::main]
async fn main() {
dotenv().ok();
let _ = setup_logger();
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set");
migration::run_migrations(&db_url);
let manager = ConnectionManager::<InstrumentedPgConnection>::new(&db_url);
let pool = Pool::builder()
.build(manager)
.expect("Could not build connection pool");
let redis_url = env::var("REDIS_URL").unwrap_or(String::from("redis://localhost"));
let redis_client =
redis::Client::open(redis_url.as_str()).expect("Could not create redis client.");
let root = endpoints::get_routes()
.layer(TraceLayer::new_for_http())
.layer(AddExtensionLayer::new(RedisSessionStore::from_client(
redis_client,
)))
.layer(AddExtensionLayer::new(pool));
let ip = env::var("IP").unwrap_or(String::from("127.0.0.1"));
let port = env::var("PORT").unwrap_or(String::from("8000"));
let addr = SocketAddr::from((
IpAddr::from_str(ip.as_str()).unwrap_or(IpAddr::from(Ipv4Addr::new(127, 0, 0, 1))),
port.parse().unwrap_or(8000),
));
log::info!("started listening on {:?}", addr);
hyper::Server::bind(&addr)
.serve(root.into_make_service())
.await
.unwrap();
}
fn setup_logger() -> () {
let log_level = env::var("LOG_LEVEL").unwrap_or(String::from("INFO"));
let subscriber = tracing_subscriber::FmtSubscriber::builder()
.with_max_level(
tracing::Level::from_str(log_level.as_str()).unwrap_or(tracing::Level::INFO),
)
.finish();
tracing_log::LogTracer::init().expect("could not init log tracer");
tracing::subscriber::set_global_default(subscriber).expect("could not set default subscriber");
// fern::Dispatch::new()
// .format(|out, message, record| {
// out.finish(format_args!(
// "{} <{}> [{}] {}",
// chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
// record.file().unwrap_or(record.target()),
// record.level(),
// message
// ))
// })
// .level(log::LevelFilter::from_str(log_level.as_str()).unwrap_or(log::LevelFilter::Info))
// .chain(std::io::stdout())
// .chain(fern::log_file("latest.log")?)
// .apply()?;
// Ok(())
}

22
src/migration.rs Normal file
View File

@ -0,0 +1,22 @@
use diesel::{prelude::*, sql_query};
use diesel_migrations::*;
pub fn reset_database(url: &String) {
let conn = PgConnection::establish(&url).expect(&format!("Error connecting to {}", url));
println!("dropping all tables");
let _ = sql_query("drop table users;").execute(&conn);
let _ = sql_query("drop table unverified_users;").execute(&conn);
let _ = sql_query("drop table blocks;").execute(&conn);
let _ = sql_query("drop table __diesel_schema_migrations;").execute(&conn);
println!("finished resetting db");
}
pub fn run_migrations(url: &String) {
println!("running migrations");
let conn = PgConnection::establish(&url).expect(&format!("Error connecting to {}", url));
let result = run_pending_migrations(&conn);
if result.is_err() {
panic!("could not run migrations: {}", result.err().unwrap());
}
println!("finished migrations");
}

38
src/models.rs Normal file
View File

@ -0,0 +1,38 @@
use super::schema::*;
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Queryable, Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct User {
pub id: Uuid,
pub discord_id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Insertable, Debug, Clone, PartialEq)]
#[table_name = "users"]
pub struct InsertableUser {
pub discord_id: String,
}
#[derive(Queryable, Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Block {
pub id: Uuid,
pub user_id: Uuid,
pub block_type: String,
pub props: serde_json::Value,
pub children: Option<Vec<String>>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Insertable, Debug, Clone, PartialEq)]
#[table_name = "blocks"]
pub struct InsertableBlock {
pub user_id: Uuid,
pub block_type: String,
pub props: serde_json::Value,
pub children: Option<Vec<String>>,
}

47
src/schema.rs Normal file
View File

@ -0,0 +1,47 @@
table! {
blocks (id) {
id -> Uuid,
user_id -> Uuid,
block_type -> Varchar,
props -> Json,
children -> Nullable<Array<Text>>,
created_at -> Timestamp,
updated_at -> Timestamp,
}
}
table! {
migration_versions (id) {
id -> Int4,
version -> Varchar,
}
}
table! {
unverifiedusers (id) {
id -> Varchar,
email -> Nullable<Varchar>,
discord_only_account -> Nullable<Bool>,
discord_id -> Nullable<Varchar>,
password_hash -> Nullable<Varchar>,
verification_token -> Nullable<Varchar>,
created_at -> Timestamp,
updated_at -> Timestamp,
}
}
table! {
users (id) {
id -> Uuid,
discord_id -> Varchar,
created_at -> Timestamp,
updated_at -> Timestamp,
}
}
allow_tables_to_appear_in_same_query!(
blocks,
migration_versions,
unverifiedusers,
users,
);

18
src/tests.rs Normal file
View File

@ -0,0 +1,18 @@
pub mod db;
#[cfg(test)]
mod tests {
// a basic test to ensure that tests are executing.
#[test]
fn works() {
assert_eq!(1, 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn db_tests() {
let url = String::from("postgres://postgres@localhost/todo_test");
crate::migration::reset_database(&url);
crate::migration::run_migrations(&url);
super::db::run_tests(&url).await;
}
}

131
src/tests/db.rs Normal file
View File

@ -0,0 +1,131 @@
use crate::helpers::*;
use crate::models::*;
use diesel::r2d2::{ConnectionManager, Pool};
use diesel_tracing::pg::InstrumentedPgConnection;
use std::{error::Error, vec::Vec};
use uuid::Uuid;
async fn get_pool(url: &String) -> Pool<ConnectionManager<InstrumentedPgConnection>> {
let manager = ConnectionManager::<InstrumentedPgConnection>::new(url);
Pool::builder()
.build(manager)
.expect("Could not build connection pool")
}
async fn user_tests(pool: &Pool<ConnectionManager<InstrumentedPgConnection>>) {
let user = InsertableUser {
discord_id: String::from("test"),
};
let created_result = user::create_user(pool, user).await;
if created_result.is_err() {
panic!("could not create user: {:?}", created_result.err());
} else {
assert!(created_result.is_ok());
}
let created_user: User = created_result.unwrap();
let mut new_user = created_user.clone();
new_user.discord_id = String::from("test2");
let user_update = new_user.clone();
let updated_result: Result<User, Box<dyn Error>> = user::update_user(pool, user_update).await;
if updated_result.is_err() {
panic!(
"cound not update user {}: {:?}",
created_user.id,
updated_result.err()
);
}
let updated_user = updated_result.unwrap();
assert_eq!(new_user.id, updated_user.id);
assert_eq!(new_user.discord_id, updated_user.discord_id);
let get_result = user::find_user_by_id(pool, created_user.id).await;
if get_result.is_err() {
panic!(
"could not find previously created user {}: {:?}",
created_user.id,
get_result.err()
);
} else {
assert_eq!(updated_user, get_result.unwrap());
}
let delete_result = user::delete_user_by_id(pool, created_user.id).await;
if delete_result.is_err() {
panic!(
"could not delete user {}: {:?}",
created_user.id,
delete_result.err()
);
}
}
async fn block_tests(pool: &Pool<ConnectionManager<InstrumentedPgConnection>>) {
let json = serde_json::from_str("[]");
let block = InsertableBlock {
user_id: Uuid::new_v4(),
block_type: String::from("test"),
children: Some(Vec::new()),
props: json.unwrap(),
};
let created_result = block::create_block(pool, block).await;
if created_result.is_err() {
panic!("could not create block: {:?}", created_result.err());
} else {
assert!(created_result.is_ok());
}
let created_block: Block = created_result.unwrap();
let mut new_block = created_block.clone();
new_block.block_type = String::from("test2");
let block_update = new_block.clone();
let updated_result: Result<Block, Box<dyn Error>> =
block::update_block(pool, block_update).await;
if updated_result.is_err() {
panic!(
"cound not update block {}: {:?}",
created_block.id,
updated_result.err()
);
}
let updated_block = updated_result.unwrap();
assert_eq!(new_block.id, updated_block.id);
assert_eq!(new_block.block_type, updated_block.block_type);
assert_eq!(new_block.user_id, updated_block.user_id);
assert_eq!(new_block.children, updated_block.children);
assert_eq!(new_block.props, updated_block.props);
let get_result = block::find_block_by_id(pool, created_block.id).await;
if get_result.is_err() {
panic!(
"could not find previously created block {}: {:?}",
created_block.id,
get_result.err()
);
} else {
assert_eq!(updated_block, get_result.unwrap());
}
let delete_result = block::delete_block_by_id(pool, created_block.id).await;
if delete_result.is_err() {
panic!(
"could not delete block {}: {:?}",
created_block.id,
delete_result.err()
);
}
}
pub async fn run_tests(url: &String) {
let pool = get_pool(url).await;
user_tests(&pool).await;
block_tests(&pool).await;
}