Compare commits

...

3 Commits

Author SHA1 Message Date
jane 24acfa5846 use jennifer for db interface 2021-07-24 17:17:10 -04:00
jane 840959ec25 crystal init 2021-07-09 23:03:57 -04:00
jane b018c0e367 remove js backend 2021-07-09 23:02:31 -04:00
43 changed files with 662 additions and 973 deletions

7
.gitignore vendored
View File

@ -127,3 +127,10 @@ pnpm-lock.yaml
backend/config.json
*.pem
/docs/
/lib/
/bin/
/.shards/
*.dwarf
Makefile
.vscode/

9
backend/.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

5
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/docs/
/lib/
/bin/
/.shards/
*.dwarf

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

6
backend/.travis.yml Normal file
View File

@ -0,0 +1,6 @@
language: crystal
# Uncomment the following if you'd like Travis to run specs and check code formatting
# script:
# - crystal spec
# - crystal tool format --check

21
backend/LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2021 Jane Petrovna <jane@j4.pm>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -1,2 +1,27 @@
# todo
# backend
TODO: Write a description here
## Installation
TODO: Write installation instructions here
## Usage
TODO: Write usage instructions here
## Development
TODO: Write development instructions here
## Contributing
1. Fork it (<https://github.com/your-github-user/backend/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request
## Contributors
- [Jane Petrovna](https://github.com/your-github-user) - creator and maintainer

View File

@ -1,16 +1,9 @@
{
"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": ""
{
"secret": "TEST_SECRET",
"port": 8080,
"db_url": "postgres://postgres:@127.0.0.1/todo",
"mail_host": "smtp.migadu.com",
"mail_port": 465,
"mail_username": "",
"mail_password": ""
}

2
backend/config/config.cr Normal file
View File

@ -0,0 +1,2 @@
require "./initializers/**"
require "../src/models/*"

View File

@ -0,0 +1,16 @@
default: &default
host: localhost
user: postgres
adapter: postgres
development:
<<: *default
db: todo_dev
test:
<<: *default
db: todo_dev
production:
<<: *default
db: todo

View File

@ -0,0 +1,12 @@
require "jennifer"
require "jennifer/adapter/postgres"
APP_ENV = ENV["APP_ENV"]? || "development"
Jennifer::Config.configure do |conf|
conf.read("config/database.yml", APP_ENV)
conf.from_uri(ENV["DATABASE_URI"]) if ENV.has_key?("DATABASE_URI")
conf.logger.level = APP_ENV == "development" ? Log::Severity::Debug : Log::Severity::Info
end
Log.setup "db", :debug, Log::IOBackend.new(formatter: Jennifer::Adapter::DBFormatter)

View File

@ -0,0 +1,3 @@
I18n.load_path += ["./config/locales"]
I18n.init

View File

View File

@ -0,0 +1,19 @@
require "jennifer"
class CreateUsers < Jennifer::Migration::Base
def up
create_table :users do |t|
t.string :id, {:primary => true}
t.string :email
t.bool :discord_only_account
t.string :discord_id, {:null => true}
t.string :password_hash, {:null => true}
t.timestamps
end
end
def down
drop_table :users if table_exists? :users
end
end

View File

@ -0,0 +1,20 @@
require "jennifer"
class CreateUnverifiedusers < Jennifer::Migration::Base
def up
create_table :unverifiedusers do |t|
t.string :id, {:primary => true}
t.string :email
t.bool :discord_only_account
t.string :discord_id, {:null => true}
t.string :password_hash, {:null => true}
t.string :verification_token
t.timestamps
end
end
def down
drop_table :unverifiedusers if table_exists? :unverifiedusers
end
end

View File

@ -0,0 +1,20 @@
require "jennifer"
class CreateTodos < Jennifer::Migration::Base
def up
create_table :todos do |t|
t.string :id, {:primary => true}
t.string :userid # remember: trying to insert a column named the same as
# another model will make the database error :)
t.text :content
t.string :tags, {:array => true, :null => true}
t.bool :complete, {:null => true}
t.date_time :deadline, {:null => true}
t.timestamps
end
end
def down
drop_table :todos if table_exists? :todos
end
end

152
backend/db/structure.sql Normal file
View File

@ -0,0 +1,152 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 13.3
-- Dumped by pg_dump version 13.3
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: migration_versions; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.migration_versions (
id integer NOT NULL,
version character varying(17) NOT NULL
);
ALTER TABLE public.migration_versions OWNER TO postgres;
--
-- Name: migration_versions_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
--
CREATE SEQUENCE public.migration_versions_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.migration_versions_id_seq OWNER TO postgres;
--
-- Name: migration_versions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
--
ALTER SEQUENCE public.migration_versions_id_seq OWNED BY public.migration_versions.id;
--
-- Name: todos; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.todos (
id character varying(254) NOT NULL,
userid character varying(254),
content text,
tags character varying(254)[],
complete boolean,
deadline timestamp without time zone,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
);
ALTER TABLE public.todos OWNER TO postgres;
--
-- Name: unverifiedusers; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.unverifiedusers (
id character varying(254) NOT NULL,
email character varying(254),
discord_only_account boolean,
discord_id character varying(254),
password_hash character varying(254),
verification_token character varying(254),
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
);
ALTER TABLE public.unverifiedusers OWNER TO postgres;
--
-- Name: users; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.users (
id character varying(254) NOT NULL,
email character varying(254),
discord_only_account boolean,
discord_id character varying(254),
password_hash character varying(254),
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
);
ALTER TABLE public.users OWNER TO postgres;
--
-- Name: migration_versions id; Type: DEFAULT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.migration_versions ALTER COLUMN id SET DEFAULT nextval('public.migration_versions_id_seq'::regclass);
--
-- Name: migration_versions migration_versions_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.migration_versions
ADD CONSTRAINT migration_versions_pkey PRIMARY KEY (id);
--
-- Name: todos todos_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.todos
ADD CONSTRAINT todos_pkey PRIMARY KEY (id);
--
-- Name: unverifiedusers unverifiedusers_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.unverifiedusers
ADD CONSTRAINT unverifiedusers_pkey PRIMARY KEY (id);
--
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
--
-- PostgreSQL database dump complete
--

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"
}
}

13
backend/sam.cr Normal file
View File

@ -0,0 +1,13 @@
require "./config/*"
require "sam"
require "./db/migrations/*"
load_dependencies "jennifer"
# Here you can define your tasks
# desc "with description to be used by help command"
# task "test" do
# puts "ping"
# end
Sam.help

78
backend/shard.lock Normal file
View File

@ -0,0 +1,78 @@
version: 2.0
shards:
ameba:
git: https://github.com/crystal-ameba/ameba.git
version: 0.14.3
backtracer:
git: https://github.com/sija/backtracer.cr.git
version: 1.2.1
base62:
git: https://github.com/janeptrv/base62.cr.git
version: 0.1.3
db:
git: https://github.com/crystal-lang/crystal-db.git
version: 0.10.1
email:
git: https://github.com/arcage/crystal-email.git
version: 0.6.3
exception_page:
git: https://github.com/crystal-loot/exception_page.git
version: 0.2.0
i18n:
git: https://github.com/techmagister/i18n.cr.git
version: 0.3.1+git.commit.b323291b772c97bc1661888eb9e82dadb722acaa
ifrit:
git: https://github.com/imdrasil/ifrit.git
version: 0.1.3
inflector:
git: https://github.com/phoffer/inflector.cr.git
version: 1.0.0
jennifer:
git: https://github.com/imdrasil/jennifer.cr.git
version: 0.11.0
kemal:
git: https://gitdab.com/luna/kemal.git
version: 1.0.0+git.commit.bba5bef50506f7572db9fcdeb107c65709bf1244
kilt:
git: https://github.com/jeromegn/kilt.git
version: 0.6.1
ksuid:
git: https://github.com/janeptrv/ksuid.cr.git
version: 0.5.2
pg:
git: https://github.com/will/crystal-pg.git
version: 0.23.2
pool:
git: https://github.com/ysbaddaden/pool.git
version: 0.2.4
radix:
git: https://github.com/luislavena/radix.git
version: 0.4.1
redis:
git: https://github.com/stefanwille/crystal-redis.git
version: 2.8.0
sam:
git: https://github.com/imdrasil/sam.cr.git
version: 0.4.1
spec-kemal:
git: https://gitdab.com/luna/spec-kemal.git
version: 1.0.0+git.commit.e4765ff11d66d705438b9c79e77665d85e4ef8f4

37
backend/shard.yml Normal file
View File

@ -0,0 +1,37 @@
name: backend
version: 0.1.0
authors:
- Jane Petrovna <jane@j4.pm>
targets:
backend:
main: src/backend.cr
crystal: 1.0.0
license: MIT
dependencies:
spec-kemal:
git: https://gitdab.com/luna/spec-kemal.git
kemal:
git: https://gitdab.com/luna/kemal.git
ksuid:
github: janeptrv/ksuid.cr
email:
github: arcage/crystal-email
pg:
github: will/crystal-pg
redis:
github: stefanwille/crystal-redis
jennifer:
github: imdrasil/jennifer.cr
version: "~> 0.11.0"
sam:
github: imdrasil/sam.cr
development_dependencies:
ameba:
github: crystal-ameba/ameba
version: ~> 0.14.0

View File

@ -0,0 +1,6 @@
require "spec"
# note: we cannot spec backend.cr
# because kemal will start automatically.
# that's fine, because there's nothing of note there
# anyways.

View File

@ -0,0 +1,61 @@
require "../spec_helper"
require "../../src/utils/config"
describe Config do
before_each {
Config.load_config
}
it "secret is present" do
secret = Config.get_config_value("secret")
secret.is_a?(JSON::Any).should be_true
if secret.is_a?(JSON::Any)
secret.as_s.empty?.should be_false
end
end
it "port is present" do
port = Config.get_config_value("port")
port.is_a?(JSON::Any).should be_true
if port.is_a?(JSON::Any)
port.as_i.is_a?(Int).should be_true
port.as_i.should_not eq(0)
end
end
it "database url is present" do
url = Config.get_config_value("db_url")
url.is_a?(JSON::Any).should be_true
if url.is_a?(JSON::Any)
url.as_s.empty?.should be_false
end
end
it "mail host and port are present" do
host = Config.get_config_value("mail_host")
host.is_a?(JSON::Any).should be_true
if host.is_a?(JSON::Any)
host.as_s.empty?.should be_false
end
port = Config.get_config_value("mail_port")
port.is_a?(JSON::Any).should be_true
if port.is_a?(JSON::Any)
port.as_i.is_a?(Int).should be_true
port.as_i.should_not eq(0)
end
end
it "mail username and password are present" do
uname = Config.get_config_value("mail_username")
uname.is_a?(JSON::Any).should be_true
if uname.is_a?(JSON::Any)
uname.as_s.empty?.should be_false
end
pword = Config.get_config_value("mail_password")
pword.is_a?(JSON::Any).should be_true
if pword.is_a?(JSON::Any)
pword.as_s.empty?.should be_false
end
end
end

32
backend/src/backend.cr Normal file
View File

@ -0,0 +1,32 @@
require "kemal"
require "log"
require "./utils/config"
require "./endpoints/user"
if !Config.is_loaded
puts "loading config"
Config.load_config
end
serve_static false
# replacement for the expressjs/sequelize backend of todo.
# because javascript sucks.
module Backend
VERSION = "0.0.1"
end
get "/api/hello" do
"Hello"
end
# this is a slightly less hilarious way to get the integer value of something
# because now i am using JSON::Any. but i am going to keep this comment
# because i want to.
port = Config.get_config_value("port")
port_to_use = port.is_a?(JSON::Any) ? port.as_i : 8000
Kemal.run do |config|
server = config.server.not_nil!
server.bind_tcp "0.0.0.0", port_to_use
end

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

View File

View File

View File

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

@ -0,0 +1,16 @@
require "jennifer"
class Todo < Jennifer::Model::Base
with_timestamps
mapping(
id: {type: String, primary: true},
userid: String,
content: String,
tags: Array(String)?,
complete: Bool?,
deadline: Time?,
created_at: Time?,
updated_at: Time?,
)
end

View File

@ -0,0 +1,16 @@
require "jennifer"
class UnverifiedUser < Jennifer::Model::Base
with_timestamps
mapping(
id: {type: String, primary: true},
email: String,
discord_only_account: {type: Bool, default: false},
discord_id: String?,
password_hash: String?,
verification_token: String,
created_at: Time?,
updated_at: Time?,
)
end

View File

@ -0,0 +1,15 @@
require "jennifer"
class User < Jennifer::Model::Base
with_timestamps
mapping(
id: {type: String, primary: true},
email: String,
discord_only_account: {type: Bool, default: false},
discord_id: String?,
password_hash: String?,
created_at: Time?,
updated_at: Time?,
)
end

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

@ -0,0 +1,53 @@
require "file"
require "json"
require "log"
class ConfigInstance
@@config = {} of String => JSON::Any
@@loaded = false
def config
@@config
end
def config=(new_value)
@@config = new_value
end
def loaded
@@loaded
end
def loaded=(new_value)
@@loaded = new_value
end
end
Instance = ConfigInstance.new
module Config
extend self
def get_config_value(key) : JSON::Any | Nil
Log.debug { "looking for #{key}" }
if Instance.config.has_key? key
Instance.config[key]
else
nil
end
end
def is_loaded : Bool
Instance.loaded
end
def load_config : Nil
loaded_config = File.open("config.json") do |file|
Hash(String, JSON::Any).from_json(file)
end
Log.debug { "loaded config is #{loaded_config}" }
Instance.config = loaded_config
Instance.loaded = true
Log.debug { "instance config is #{Instance.config}" }
end
end

View File

@ -0,0 +1,5 @@
require "jennifer"
require "redis"
module DatabaseCaching
end

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
});
});

View File

@ -24,10 +24,10 @@
"web-vitals": "^1.1.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"start": "BROWSER=none react-scripts start",
"build": "BROWSER=none react-scripts build",
"test": "BROWSER=none react-scripts test",
"eject": "BROWSER=none react-scripts eject"
},
"eslintConfig": {
"extends": [