Compare commits
3 commits
piss
...
24acfa5846
Author | SHA1 | Date | |
---|---|---|---|
24acfa5846 | |||
840959ec25 | |||
b018c0e367 |
43 changed files with 662 additions and 973 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -127,3 +127,10 @@ pnpm-lock.yaml
|
||||||
backend/config.json
|
backend/config.json
|
||||||
|
|
||||||
*.pem
|
*.pem
|
||||||
|
/docs/
|
||||||
|
/lib/
|
||||||
|
/bin/
|
||||||
|
/.shards/
|
||||||
|
*.dwarf
|
||||||
|
Makefile
|
||||||
|
.vscode/
|
||||||
|
|
9
backend/.editorconfig
Normal file
9
backend/.editorconfig
Normal 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
5
backend/.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
/docs/
|
||||||
|
/lib/
|
||||||
|
/bin/
|
||||||
|
/.shards/
|
||||||
|
*.dwarf
|
|
@ -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
6
backend/.travis.yml
Normal 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
21
backend/LICENSE
Normal 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.
|
|
@ -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
|
||||||
|
|
|
@ -1,16 +1,9 @@
|
||||||
{
|
{
|
||||||
"secret": "TEST_SECRET",
|
"secret": "TEST_SECRET",
|
||||||
"https": true,
|
|
||||||
"alter_db": true,
|
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"frontend_url": "localhost:3000",
|
|
||||||
"db_url": "postgres://postgres:@127.0.0.1/todo",
|
"db_url": "postgres://postgres:@127.0.0.1/todo",
|
||||||
"cert": "",
|
"mail_host": "smtp.migadu.com",
|
||||||
"cert_key": "",
|
|
||||||
"mail_host": "",
|
|
||||||
"mail_port": 465,
|
"mail_port": 465,
|
||||||
"mail_username": "",
|
"mail_username": "",
|
||||||
"mail_password": "",
|
"mail_password": ""
|
||||||
"discord_id": "",
|
|
||||||
"discord_secret": ""
|
|
||||||
}
|
}
|
2
backend/config/config.cr
Normal file
2
backend/config/config.cr
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
require "./initializers/**"
|
||||||
|
require "../src/models/*"
|
16
backend/config/database.yml
Normal file
16
backend/config/database.yml
Normal 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
|
12
backend/config/initializers/database.cr
Normal file
12
backend/config/initializers/database.cr
Normal 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)
|
3
backend/config/initializers/zzz_i18n.cr
Normal file
3
backend/config/initializers/zzz_i18n.cr
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
I18n.load_path += ["./config/locales"]
|
||||||
|
|
||||||
|
I18n.init
|
0
backend/config/locales/en.yml
Normal file
0
backend/config/locales/en.yml
Normal file
19
backend/db/migrations/20210723170518920_create_users.cr
Normal file
19
backend/db/migrations/20210723170518920_create_users.cr
Normal 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
|
|
@ -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
|
20
backend/db/migrations/20210723171731912_create_todos.cr
Normal file
20
backend/db/migrations/20210723171731912_create_todos.cr
Normal 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
152
backend/db/structure.sql
Normal 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
|
||||||
|
--
|
||||||
|
|
|
@ -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
13
backend/sam.cr
Normal 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
78
backend/shard.lock
Normal 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
37
backend/shard.yml
Normal 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
|
6
backend/spec/spec_helper.cr
Normal file
6
backend/spec/spec_helper.cr
Normal 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.
|
61
backend/spec/utils/config_spec.cr
Normal file
61
backend/spec/utils/config_spec.cr
Normal 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
32
backend/src/backend.cr
Normal 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
|
|
@ -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();
|
|
|
@ -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)
|
|
||||||
};
|
|
0
backend/src/endpoints/auth.cr
Normal file
0
backend/src/endpoints/auth.cr
Normal file
0
backend/src/endpoints/register.cr
Normal file
0
backend/src/endpoints/register.cr
Normal file
0
backend/src/endpoints/todo.cr
Normal file
0
backend/src/endpoints/todo.cr
Normal file
0
backend/src/endpoints/user.cr
Normal file
0
backend/src/endpoints/user.cr
Normal 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'}`
|
|
||||||
);
|
|
|
@ -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;
|
|
|
@ -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;
|
|
16
backend/src/models/todo.cr
Normal file
16
backend/src/models/todo.cr
Normal 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
|
16
backend/src/models/unverified_user.cr
Normal file
16
backend/src/models/unverified_user.cr
Normal 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
|
15
backend/src/models/user.cr
Normal file
15
backend/src/models/user.cr
Normal 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
|
|
@ -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
|
|
||||||
};
|
|
|
@ -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
|
|
||||||
};
|
|
53
backend/src/utils/config.cr
Normal file
53
backend/src/utils/config.cr
Normal 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
|
5
backend/src/utils/database_caching.cr
Normal file
5
backend/src/utils/database_caching.cr
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
require "jennifer"
|
||||||
|
require "redis"
|
||||||
|
|
||||||
|
module DatabaseCaching
|
||||||
|
end
|
|
@ -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()));
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -24,10 +24,10 @@
|
||||||
"web-vitals": "^1.1.2"
|
"web-vitals": "^1.1.2"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "BROWSER=none react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "BROWSER=none react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "BROWSER=none react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"eject": "BROWSER=none react-scripts eject"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
|
|
Loading…
Reference in a new issue