├── migrations ├── .gitkeep ├── 2018-09-19-230811_create_users │ ├── down.sql │ └── up.sql ├── 2018-09-27-123502_create_cards │ ├── down.sql │ └── up.sql ├── 2018-09-21-133954_create_tokens │ ├── down.sql │ └── up.sql ├── 2018-10-29-155232_create_useful_marks │ ├── down.sql │ └── up.sql ├── 2019-10-13-173624_add_index_to_username │ ├── down.sql │ └── up.sql ├── 2018-10-29-194654_add_useful_for_to_card │ ├── down.sql │ └── up.sql ├── 2018-11-01-144912_add_displayname_to_users │ ├── down.sql │ └── up.sql ├── 2019-09-28-091446_add_gravatar_email │ ├── down.sql │ └── up.sql ├── 2019-11-27-071112_add_preview_url_to_card │ ├── down.sql │ └── up.sql ├── 2019-10-13-174104_rename_index_for_users_email │ ├── down.sql │ └── up.sql ├── 2018-09-28-153135_add_updated_at_to_cards │ ├── down.sql │ └── up.sql ├── 2019-09-28-140611_add_username_and_default_username │ ├── down.sql │ └── up.sql ├── 2019-02-18-194651_add_content_for_search_to_card │ ├── down.sql │ └── up.sql ├── 2019-12-15-115212_add_tags_to_cards │ ├── down.sql │ └── up.sql ├── 2019-02-16-211142_card_change_content_to_jsonb │ ├── down.sql │ └── up.sql └── 00000000000000_diesel_initial_setup │ ├── down.sql │ └── up.sql ├── internal-api ├── .rustfmt.toml ├── README.md ├── Cargo.toml └── src │ ├── main.rs │ ├── answer.rs │ └── handlers.rs ├── public-api ├── .rustfmt.toml ├── src │ ├── handlers │ │ ├── users │ │ │ ├── mod.rs │ │ │ ├── get_user.rs │ │ │ ├── cards_by_author.rs │ │ │ └── useful_cards.rs │ │ ├── account │ │ │ ├── mod.rs │ │ │ ├── session_fetch.rs │ │ │ ├── create.rs │ │ │ ├── login.rs │ │ │ └── update.rs │ │ ├── mod.rs │ │ ├── cards │ │ │ ├── mod.rs │ │ │ ├── get.rs │ │ │ ├── list.rs │ │ │ ├── delete.rs │ │ │ ├── create.rs │ │ │ ├── toggle_useful_mark.rs │ │ │ └── edit.rs │ │ └── search │ │ │ └── mod.rs │ ├── consts.rs │ ├── gravatar.rs │ ├── models │ │ ├── mod.rs │ │ ├── token.rs │ │ ├── useful_mark.rs │ │ ├── user.rs │ │ └── card.rs │ ├── time.rs │ ├── validators.rs │ ├── routes │ │ ├── mod.rs │ │ ├── search.rs │ │ ├── account.rs │ │ ├── users.rs │ │ └── cards.rs │ ├── preview.rs │ ├── app_state.rs │ ├── prelude.rs │ ├── layer.rs │ ├── hasher.rs │ ├── views.rs │ ├── auth_token.rs │ ├── main.rs │ ├── auth.rs │ └── slate │ │ ├── mod.rs │ │ ├── example_slate_document.json │ │ └── big_example.json ├── makefile ├── pm.sh ├── Cargo.toml └── README.md ├── db ├── src │ ├── lib.rs │ └── schema.rs └── Cargo.toml ├── .cargo └── config ├── .rusty-hook.toml ├── Cargo.toml ├── README.md ├── create-db.sql ├── diesel.toml ├── .dockerignore ├── start-tools.Dockerfile ├── rfc └── 1-jsonrpc.md ├── .env.sample ├── deployment └── howtocards.service ├── docker-entrypoint.sh ├── .github ├── FUNDING.yml └── workflows │ ├── api-public.yml │ └── api-internal.yml ├── .travis.yml ├── Dockerfile ├── .gitignore └── swagger.yml /migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal-api/.rustfmt.toml: -------------------------------------------------------------------------------- 1 | use_field_init_shorthand = true 2 | -------------------------------------------------------------------------------- /public-api/.rustfmt.toml: -------------------------------------------------------------------------------- 1 | use_field_init_shorthand = true 2 | -------------------------------------------------------------------------------- /migrations/2018-09-19-230811_create_users/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE users; 2 | -------------------------------------------------------------------------------- /migrations/2018-09-27-123502_create_cards/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE cards; 2 | -------------------------------------------------------------------------------- /migrations/2018-09-21-133954_create_tokens/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE tokens; 2 | -------------------------------------------------------------------------------- /db/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | pub extern crate diesel; 3 | 4 | pub mod schema; 5 | -------------------------------------------------------------------------------- /migrations/2018-10-29-155232_create_useful_marks/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE useful_marks; 2 | -------------------------------------------------------------------------------- /.cargo/config: -------------------------------------------------------------------------------- 1 | [alias] 2 | run-crate = ["run", "--package", "howtocards-internal-api"] 3 | -------------------------------------------------------------------------------- /migrations/2019-10-13-173624_add_index_to_username/down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX users_username; 2 | -------------------------------------------------------------------------------- /.rusty-hook.toml: -------------------------------------------------------------------------------- 1 | [hooks] 2 | # pre-commit = "cargo test" 3 | 4 | [logging] 5 | verbose = true 6 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | "public-api", 5 | "internal-api", 6 | "db", 7 | ] 8 | -------------------------------------------------------------------------------- /migrations/2018-10-29-194654_add_useful_for_to_card/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE cards DROP COLUMN useful_for; 2 | -------------------------------------------------------------------------------- /migrations/2018-11-01-144912_add_displayname_to_users/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users DROP COLUMN display_name; 2 | -------------------------------------------------------------------------------- /migrations/2019-09-28-091446_add_gravatar_email/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users" DROP COLUMN "gravatar_email"; 2 | -------------------------------------------------------------------------------- /migrations/2019-11-27-071112_add_preview_url_to_card/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "cards" DROP COLUMN "preview_url"; 2 | -------------------------------------------------------------------------------- /public-api/src/handlers/users/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cards_by_author; 2 | pub mod get_user; 3 | pub mod useful_cards; 4 | -------------------------------------------------------------------------------- /migrations/2018-11-01-144912_add_displayname_to_users/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users ADD COLUMN display_name VARCHAR; 2 | -------------------------------------------------------------------------------- /migrations/2019-10-13-174104_rename_index_for_users_email/down.sql: -------------------------------------------------------------------------------- 1 | ALTER INDEX users_email RENAME TO index_email; 2 | -------------------------------------------------------------------------------- /migrations/2019-10-13-174104_rename_index_for_users_email/up.sql: -------------------------------------------------------------------------------- 1 | ALTER INDEX index_email RENAME TO users_email; 2 | -------------------------------------------------------------------------------- /migrations/2018-09-28-153135_add_updated_at_to_cards/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."cards" DROP COLUMN "updated_at"; 2 | -------------------------------------------------------------------------------- /migrations/2019-09-28-140611_add_username_and_default_username/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users" DROP COLUMN "username"; 2 | -------------------------------------------------------------------------------- /migrations/2018-10-29-194654_add_useful_for_to_card/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE cards ADD COLUMN useful_for int8 NOT NULL DEFAULT 0; 2 | -------------------------------------------------------------------------------- /migrations/2019-02-18-194651_add_content_for_search_to_card/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "cards" DROP COLUMN "content_for_search"; 2 | -------------------------------------------------------------------------------- /migrations/2019-09-28-091446_add_gravatar_email/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users" ADD COLUMN "gravatar_email" VARCHAR DEFAULT NULL; 2 | -------------------------------------------------------------------------------- /public-api/src/consts.rs: -------------------------------------------------------------------------------- 1 | //! Perproject constacts 2 | 3 | /// Used in password hashing 4 | pub const SALT: &str = "SALT"; 5 | -------------------------------------------------------------------------------- /migrations/2019-10-13-173624_add_index_to_username/up.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX users_username ON users USING BTREE (username); 2 | -------------------------------------------------------------------------------- /migrations/2019-11-27-071112_add_preview_url_to_card/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "cards" ADD COLUMN "preview_url" VARCHAR DEFAULT NULL; 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Backend 2 | 3 | ## Docker 4 | 5 | ### public-api 6 | 7 | ```sh 8 | docker build -f public-api.Dockerfile . 9 | ``` 10 | -------------------------------------------------------------------------------- /migrations/2018-09-28-153135_add_updated_at_to_cards/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."cards" ADD COLUMN "updated_at" timestamp DEFAULT NOW(); 2 | -------------------------------------------------------------------------------- /migrations/2019-12-15-115212_add_tags_to_cards/down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX "public"."cards_tags"; 2 | ALTER TABLE "public"."cards" DROP COLUMN "tags"; 3 | -------------------------------------------------------------------------------- /migrations/2019-02-18-194651_add_content_for_search_to_card/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "cards" ADD COLUMN "content_for_search" VARCHAR NOT NULL DEFAULT ''; 2 | -------------------------------------------------------------------------------- /public-api/src/handlers/account/mod.rs: -------------------------------------------------------------------------------- 1 | //! Account handlers 2 | pub mod create; 3 | pub mod login; 4 | pub mod session_fetch; 5 | pub mod update; 6 | -------------------------------------------------------------------------------- /public-api/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | //! Actors that handles requests from routes 2 | 3 | pub mod account; 4 | pub mod cards; 5 | pub mod search; 6 | pub mod users; 7 | -------------------------------------------------------------------------------- /internal-api/README.md: -------------------------------------------------------------------------------- 1 | # internal api 2 | 3 | ## Configuration 4 | 5 | with env var: 6 | 7 | - **LISTEN** — host and port to listen on. default `localhost:9002` 8 | -------------------------------------------------------------------------------- /create-db.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE howtocards; 2 | CREATE ROLE howtocards WITH ENCRYPTED PASSWORD 'howtocards'; 3 | GRANT ALL PRIVILEGES ON DATABASE howtocards TO howtocards; 4 | -------------------------------------------------------------------------------- /diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "db/src/schema.rs" 6 | -------------------------------------------------------------------------------- /public-api/src/gravatar.rs: -------------------------------------------------------------------------------- 1 | 2 | pub fn create_avatar_url(email: String) -> String { 3 | format!("https://www.gravatar.com/avatar/{:x}?rating=g&d=retro", md5::compute(email)) 4 | } 5 | -------------------------------------------------------------------------------- /public-api/src/handlers/cards/mod.rs: -------------------------------------------------------------------------------- 1 | //! Cards handlers 2 | 3 | pub mod create; 4 | pub mod delete; 5 | pub mod edit; 6 | pub mod get; 7 | pub mod list; 8 | pub mod toggle_useful_mark; 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | .env.sample 3 | .git 4 | .gitignore 5 | .travis.yml 6 | **/*.rs.bk 7 | docker-compose.yml 8 | Dockerfile 9 | README.md 10 | rfc 11 | target 12 | tmp 13 | -------------------------------------------------------------------------------- /start-tools.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:9-slim 2 | 3 | RUN seq 1 8 | xargs -I{} mkdir -p /usr/share/man/man{} && \ 4 | apt update && \ 5 | apt -y install libpq-dev postgresql-client && \ 6 | apt clean 7 | -------------------------------------------------------------------------------- /migrations/2018-09-21-133954_create_tokens/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tokens ( 2 | token VARCHAR PRIMARY KEY, 3 | user_id INTEGER NOT NULL REFERENCES users(id) 4 | ); 5 | CREATE INDEX index_user_id ON tokens(user_id); 6 | -------------------------------------------------------------------------------- /migrations/2019-12-15-115212_add_tags_to_cards/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."cards" ADD COLUMN "tags" text[] NOT NULL DEFAULT array[]::text[]; 2 | CREATE INDEX "cards_tags" ON "public"."cards" USING BTREE ("tags"); 3 | -------------------------------------------------------------------------------- /migrations/2018-09-19-230811_create_users/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id SERIAL PRIMARY KEY, 3 | email VARCHAR NOT NULL, 4 | password VARCHAR NOT NULL 5 | ); 6 | CREATE UNIQUE INDEX index_email ON users(email); 7 | -------------------------------------------------------------------------------- /migrations/2019-02-16-211142_card_change_content_to_jsonb/down.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "useful_marks"; 2 | DELETE FROM "cards"; 3 | ALTER TABLE "cards" DROP COLUMN "content"; 4 | ALTER TABLE "cards" ADD COLUMN "content" varchar NOT NULL; 5 | -------------------------------------------------------------------------------- /migrations/2019-02-16-211142_card_change_content_to_jsonb/up.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "useful_marks"; 2 | DELETE FROM "cards"; 3 | ALTER TABLE "cards" DROP COLUMN "content"; 4 | ALTER TABLE "cards" ADD COLUMN "content" jsonb NOT NULL; 5 | -------------------------------------------------------------------------------- /public-api/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub use self::card::{Card, CardNew}; 2 | pub use self::token::Token; 3 | pub use self::useful_mark::UsefulMark; 4 | pub use self::user::{User, UserNew, Credentials}; 5 | 6 | pub mod card; 7 | pub mod token; 8 | pub mod useful_mark; 9 | pub mod user; 10 | -------------------------------------------------------------------------------- /public-api/src/time.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | 3 | pub fn now() -> NaiveDateTime { 4 | use chrono::DateTime; 5 | use chrono::Utc; 6 | 7 | let datetime: DateTime = Utc::now(); 8 | 9 | NaiveDateTime::from_timestamp(datetime.timestamp(), 0) 10 | } 11 | -------------------------------------------------------------------------------- /rfc/1-jsonrpc.md: -------------------------------------------------------------------------------- 1 | 2 | ## Procedures 3 | 4 | ### `account.create` 5 | 6 | ### `account.login` 7 | 8 | ### `account.get_session` 9 | 10 | ### `cards.create` 11 | 12 | ### `cards.edit` 13 | 14 | ### `cards.get_list` 15 | 16 | ### `cards.get` 17 | 18 | ### `cards.delete` 19 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=howtocards 2 | POSTGRES_PASSWORD=howtocards 3 | POSTGRES_DB=howtocards 4 | 5 | DATABASE_HOST=localhost 6 | # DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB} 7 | DATABASE_URL=postgres://howtocards:howtocards@localhost/howtocards 8 | -------------------------------------------------------------------------------- /migrations/2019-09-28-140611_add_username_and_default_username/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users" ADD COLUMN "username" VARCHAR; 2 | 3 | UPDATE users 4 | SET username = CONCAT('id', users.id) 5 | FROM users comp 6 | WHERE users.id = comp.id; 7 | 8 | ALTER TABLE "users" ALTER COLUMN "username" SET NOT NULL; 9 | -------------------------------------------------------------------------------- /migrations/2018-09-27-123502_create_cards/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE cards ( 2 | id SERIAL PRIMARY KEY, 3 | author_id INTEGER NOT NULL REFERENCES users(id), 4 | title VARCHAR NOT NULL, 5 | content VARCHAR NOT NULL, 6 | created_at TIMESTAMP DEFAULT NOW() 7 | ); 8 | CREATE INDEX index_author_id ON cards(author_id); 9 | -------------------------------------------------------------------------------- /public-api/src/validators.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | 3 | pub fn check_username>(username: T) -> bool { 4 | lazy_static! { 5 | static ref RE: Regex = 6 | Regex::new(r"^[A-Za-z0-9_][A-Za-z0-9_]+(?:[ \-\._][A-Za-z0-9]+)*$").expect("username regex"); 7 | } 8 | 9 | RE.is_match(username.as_ref()) 10 | } 11 | -------------------------------------------------------------------------------- /deployment/howtocards.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=The Howtocards Backend 3 | 4 | [Service] 5 | Environment=RUST_LOG=info 6 | User=www 7 | WorkingDirectory=/home/www/backend 8 | ExecStart=/home/www/backend/target/release/howtocards-public-api 9 | Restart=on-failure 10 | RestartSec=55 11 | TimeoutStopSec=10 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | cmd="$@" 6 | 7 | until PGPASSWORD=${POSTGRES_PASSWORD} psql -h ${DATABASE_HOST} -U ${POSTGRES_USER} ${POSTGRES_DB} -c '\q'; do 8 | >&2 echo "Postgres is unavailable - sleeping" 9 | sleep 1 10 | done 11 | 12 | >&2 echo "Postgres is up - executing command" 13 | cd /app && diesel migration run && exec $@ 14 | -------------------------------------------------------------------------------- /migrations/2018-10-29-155232_create_useful_marks/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE useful_marks ( 2 | card_id INTEGER NOT NULL REFERENCES cards(id), 3 | user_id INTEGER NOT NULL REFERENCES users(id), 4 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 5 | PRIMARY KEY (card_id, user_id) 6 | ); 7 | CREATE INDEX ON useful_marks(card_id); 8 | CREATE INDEX ON useful_marks(user_id); 9 | -------------------------------------------------------------------------------- /migrations/00000000000000_diesel_initial_setup/down.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); 6 | DROP FUNCTION IF EXISTS diesel_set_updated_at(); 7 | -------------------------------------------------------------------------------- /db/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "howtocards-db" 3 | version = "0.4.3" 4 | authors = ["Sergey Sova "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | diesel = { version = "1.4.3", features = ["postgres", "chrono", "serde_json"] } 11 | serde = "1.0.102" 12 | serde_json = "1.0.41" 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | # github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: sergeysova 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # custom: # Replace with a single custom sponsorship URL 9 | -------------------------------------------------------------------------------- /public-api/makefile: -------------------------------------------------------------------------------- 1 | docker-init: 2 | cp .env.sample .env 3 | make docker-start 4 | 5 | docker-start: 6 | docker-compose up -d 7 | 8 | docker-stop: 9 | docker-compose stop 10 | 11 | docker-down: 12 | docker-compose down 13 | rm -r ./tmp || true 14 | rm .env || true 15 | 16 | docker-shell-backend: 17 | docker exec -it howtocards-public-api bash 18 | 19 | docker-migration: 20 | docker exec -i howtocards-public-api bash -c 'cd /app && diesel migration run' 21 | -------------------------------------------------------------------------------- /public-api/pm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | start() { 4 | kill; 5 | nohup ../target/release/howtocards-public-api & 6 | echo !$ > /tmp/howtocards-public-api.pid 7 | } 8 | 9 | kill() { 10 | cat /tmp/howtocards-public-api.pid | xargs kill -9 2> /dev/null || true 11 | } 12 | 13 | case "$1" in 14 | "start") 15 | start 16 | ;; 17 | 18 | "stop") 19 | kill 20 | ;; 21 | 22 | "restart") 23 | kill 24 | start 25 | ;; 26 | 27 | "test") 28 | echo "Hello Test Pm" 29 | ;; 30 | esac 31 | -------------------------------------------------------------------------------- /public-api/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | //! Handle requests and send to handlers 2 | 3 | use actix_web::*; 4 | 5 | pub mod account; 6 | pub mod cards; 7 | pub mod search; 8 | pub mod users; 9 | 10 | use crate::app_state::AppState; 11 | 12 | #[inline] 13 | pub fn scope(scope: Scope) -> Scope { 14 | scope 15 | .nested("/account", account::scope) 16 | .nested("/cards", cards::scope) 17 | .nested("/search", search::scope) 18 | .nested("/users", users::scope) 19 | } 20 | -------------------------------------------------------------------------------- /public-api/src/handlers/users/get_user.rs: -------------------------------------------------------------------------------- 1 | use actix_base::prelude::*; 2 | 3 | use crate::app_state::DbExecutor; 4 | use crate::models::*; 5 | 6 | pub struct GetUser { 7 | pub username: String, 8 | } 9 | 10 | impl Message for GetUser { 11 | type Result = Option; 12 | } 13 | 14 | impl Handler for DbExecutor { 15 | type Result = Option; 16 | 17 | fn handle(&mut self, msg: GetUser, _ctx: &mut Self::Context) -> Self::Result { 18 | User::find_by_username(&self.conn, msg.username) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/api-public.yml: -------------------------------------------------------------------------------- 1 | name: API Public CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | 9 | jobs: 10 | public-api: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | 16 | - name: Publish Docker 17 | uses: elgohr/Publish-Docker-Github-Action@2.8 18 | with: 19 | name: howtocards/backend/public-api 20 | username: sergeysova 21 | password: ${{ secrets.GITHUB_TOKEN }} 22 | snapshot: true 23 | tagging: ${{contains(github.ref, 'refs/tags/v')}} 24 | registry: docker.pkg.github.com 25 | buildargs: CRATE_NAME=public-api 26 | -------------------------------------------------------------------------------- /.github/workflows/api-internal.yml: -------------------------------------------------------------------------------- 1 | name: API Internal CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | 9 | jobs: 10 | internal-api: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | 16 | - name: Publish Docker 17 | uses: elgohr/Publish-Docker-Github-Action@2.8 18 | with: 19 | name: howtocards/backend/internal-api 20 | username: sergeysova 21 | password: ${{ secrets.GITHUB_TOKEN }} 22 | snapshot: true 23 | tagging: ${{contains(github.ref, 'refs/tags/v')}} 24 | registry: docker.pkg.github.com 25 | buildargs: CRATE_NAME=internal-api 26 | -------------------------------------------------------------------------------- /public-api/src/preview.rs: -------------------------------------------------------------------------------- 1 | use failure::Error; 2 | use std::collections::HashMap; 3 | 4 | #[derive(Debug, Serialize)] 5 | struct PreviewCard { 6 | card: String, 7 | callback: String, 8 | extra: HashMap, 9 | } 10 | 11 | pub fn create_for_card(card_id: u32, preview_queue_url: String) -> Result<(), Error> { 12 | reqwest::Client::new() 13 | .post(&format!("{}/render/card", preview_queue_url)) 14 | .json(&PreviewCard { 15 | card: card_id.to_string(), 16 | callback: format!("/preview/card/{}", card_id), 17 | extra: Default::default(), 18 | }) 19 | .send() 20 | .map(|_| {}) 21 | .map_err(|e| e.into()) 22 | } 23 | -------------------------------------------------------------------------------- /public-api/src/handlers/account/session_fetch.rs: -------------------------------------------------------------------------------- 1 | ///! Fetch user from session token 2 | use actix_base::prelude::*; 3 | 4 | use crate::app_state::DbExecutor; 5 | use crate::models::*; 6 | 7 | /// Fetch user account from session token 8 | /// 9 | /// Should be sended to DbExecutor 10 | #[derive(Debug)] 11 | pub struct AccountSessionFetch { 12 | pub token: String, 13 | } 14 | 15 | impl Message for AccountSessionFetch { 16 | type Result = Option; 17 | } 18 | 19 | impl Handler for DbExecutor { 20 | type Result = Option; 21 | 22 | fn handle(&mut self, msg: AccountSessionFetch, _ctx: &mut Self::Context) -> Self::Result { 23 | User::find_by_token(&self.conn, msg.token) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /public-api/src/app_state.rs: -------------------------------------------------------------------------------- 1 | //! Application state types 2 | 3 | use actix_base::prelude::*; 4 | use actix_web::HttpRequest; 5 | use diesel::prelude::*; 6 | 7 | /// Actor with connection to postgres 8 | pub struct DbExecutor { 9 | pub conn: PgConnection, 10 | } 11 | 12 | /// Receives database updates 13 | impl Actor for DbExecutor { 14 | type Context = SyncContext; 15 | } 16 | 17 | impl DbExecutor { 18 | pub fn new(conn: PgConnection) -> Self { 19 | DbExecutor { conn } 20 | } 21 | } 22 | 23 | /// That state passes to each request 24 | pub struct AppState { 25 | /// Postgres connection actor 26 | pub pg: Addr, 27 | pub preview_queue_url: String, 28 | } 29 | 30 | pub type Req = HttpRequest; 31 | -------------------------------------------------------------------------------- /public-api/src/handlers/cards/get.rs: -------------------------------------------------------------------------------- 1 | //! Get single card 2 | 3 | use actix_base::prelude::*; 4 | 5 | use crate::app_state::DbExecutor; 6 | use crate::models::*; 7 | 8 | /// Fetch single card 9 | /// 10 | /// Should be sended to DbExecutor 11 | pub struct CardFetch { 12 | pub card_id: u32, 13 | pub requester_id: Option, 14 | } 15 | 16 | impl Message for CardFetch { 17 | type Result = Option; 18 | } 19 | 20 | impl Handler for DbExecutor { 21 | type Result = Option; 22 | 23 | fn handle(&mut self, msg: CardFetch, _ctx: &mut Self::Context) -> Self::Result { 24 | Card::find_by_id( 25 | &self.conn, 26 | msg.card_id as i32, 27 | msg.requester_id.unwrap_or(-1), 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Sergey Sova "] 3 | description = "Howtocards internal API" 4 | edition = "2018" 5 | license = "MIT" 6 | name = "howtocards-internal-api" 7 | repository = "https://github.com/howtocards/backend" 8 | version = "0.4.3" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [[bin]] 13 | name = "howtocards-internal-api" 14 | path = "./src/main.rs" 15 | 16 | [dependencies] 17 | actix-rt = "1.0.0-alpha.1" 18 | actix-web = "2.0.0-alpha.1" 19 | actix-router = "0.1.5" 20 | diesel = { version = "1.4.3", features = ["postgres", "r2d2", "chrono", "serde_json"] } 21 | serde = { version = "1.0.102", features = ["derive"] } 22 | serde_json = "1.0.41" 23 | env_logger = "0.7.1" 24 | futures = "0.3.1" 25 | howtocards-db = { "path" = "../db" } 26 | r2d2 = "0.8.6" 27 | dotenv = "0.15.0" 28 | actix-http = "0.3.0-alpha.1" 29 | failure = "0.1.6" 30 | -------------------------------------------------------------------------------- /public-api/src/handlers/cards/list.rs: -------------------------------------------------------------------------------- 1 | //! Cards list 2 | 3 | use actix_base::prelude::*; 4 | 5 | use crate::app_state::DbExecutor; 6 | use crate::models::*; 7 | 8 | /// Fetch all cards 9 | /// 10 | /// TODO: need params 11 | /// Should be sended to DbExecutor 12 | pub struct CardsListFetch { 13 | pub requester_id: Option, 14 | pub count: Option, 15 | } 16 | 17 | const DEFAULT_COUNT: u32 = 20; 18 | const MAX_COUNT: u32 = 50; 19 | 20 | impl Message for CardsListFetch { 21 | type Result = Option>; 22 | } 23 | 24 | impl Handler for DbExecutor { 25 | type Result = Option>; 26 | 27 | fn handle(&mut self, msg: CardsListFetch, _ctx: &mut Self::Context) -> Self::Result { 28 | Some(Card::get_latest_cards( 29 | &self.conn, 30 | msg.requester_id.unwrap_or(-1), 31 | std::cmp::max(msg.count.unwrap_or(DEFAULT_COUNT), MAX_COUNT), 32 | )) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /public-api/src/models/token.rs: -------------------------------------------------------------------------------- 1 | use crate::models::User; 2 | use diesel::prelude::*; 3 | use howtocards_db::schema::tokens; 4 | use uuid::Uuid; 5 | 6 | #[derive(Debug, Queryable, Serialize, Insertable, Deserialize, Associations, Identifiable)] 7 | #[belongs_to(User)] 8 | #[primary_key(token)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct Token { 11 | pub token: String, 12 | pub user_id: i32, 13 | } 14 | 15 | impl Token { 16 | pub fn generate() -> String { 17 | format!("{}-{}", Uuid::new_v4(), Uuid::new_v4()) 18 | } 19 | 20 | pub fn new(user_id: i32) -> Self { 21 | Token { 22 | token: Self::generate(), 23 | user_id, 24 | } 25 | } 26 | 27 | pub fn create(conn: &PgConnection, user_id: i32) -> Option { 28 | let token = Self::new(user_id); 29 | 30 | diesel::insert_into(tokens::table) 31 | .values(&token) 32 | .get_result(conn) 33 | .ok() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public-api/src/handlers/users/cards_by_author.rs: -------------------------------------------------------------------------------- 1 | use actix_base::prelude::*; 2 | use std::cmp; 3 | 4 | use crate::app_state::DbExecutor; 5 | use crate::models::*; 6 | 7 | pub struct GetCardsByAuthor { 8 | pub author_username: String, 9 | pub count: Option, 10 | } 11 | 12 | const DEFAULT_COUNT: u32 = 20; 13 | const MAX_COUNT: u32 = 50; 14 | 15 | impl Message for GetCardsByAuthor { 16 | type Result = Option>; 17 | } 18 | 19 | impl Handler for DbExecutor { 20 | type Result = Option>; 21 | 22 | fn handle(&mut self, msg: GetCardsByAuthor, _ctx: &mut Self::Context) -> Self::Result { 23 | if let Some(user) = User::find_by_username(&self.conn, msg.author_username) { 24 | Some(Card::find_all_by_author( 25 | &self.conn, 26 | user.id, 27 | cmp::max(msg.count.unwrap_or(DEFAULT_COUNT), MAX_COUNT), 28 | )) 29 | } else { 30 | None 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /public-api/src/handlers/users/useful_cards.rs: -------------------------------------------------------------------------------- 1 | use actix_base::prelude::*; 2 | use std::cmp; 3 | 4 | use crate::app_state::DbExecutor; 5 | use crate::models::*; 6 | 7 | pub struct GetUsefulCardsForUser { 8 | pub username: String, 9 | pub count: Option, 10 | } 11 | 12 | const DEFAULT_COUNT: u32 = 20; 13 | const MAX_COUNT: u32 = 50; 14 | 15 | impl Message for GetUsefulCardsForUser { 16 | type Result = Option>; 17 | } 18 | 19 | impl Handler for DbExecutor { 20 | type Result = Option>; 21 | 22 | fn handle(&mut self, msg: GetUsefulCardsForUser, _ctx: &mut Self::Context) -> Self::Result { 23 | if let Some(user) = User::find_by_username(&self.conn, msg.username) { 24 | Some(Card::get_useful_for_user( 25 | &self.conn, 26 | user.id, 27 | cmp::max(msg.count.unwrap_or(DEFAULT_COUNT), MAX_COUNT), 28 | )) 29 | } else { 30 | None 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /public-api/src/handlers/cards/delete.rs: -------------------------------------------------------------------------------- 1 | //! Delete existing card 2 | 3 | use actix_base::prelude::*; 4 | use actix_web::*; 5 | 6 | use crate::app_state::DbExecutor; 7 | use crate::models::*; 8 | use crate::prelude::*; 9 | 10 | #[derive(Fail, Debug)] 11 | pub enum CardDeleteError { 12 | /// When user is not author of the card 13 | #[fail(display = "no_access")] 14 | NoRights, 15 | } 16 | 17 | impl_response_error_for!(CardDeleteError as Forbidden); 18 | 19 | pub struct CardDelete { 20 | pub card_id: u32, 21 | /// User id who requested delete 22 | pub requester_id: i32, 23 | } 24 | 25 | /// Message returns deleted card 26 | impl Message for CardDelete { 27 | type Result = Result; 28 | } 29 | 30 | impl Handler for DbExecutor { 31 | type Result = Result; 32 | 33 | fn handle(&mut self, msg: CardDelete, _ctx: &mut Self::Context) -> Self::Result { 34 | Card::delete(&self.conn, msg.card_id as i32, msg.requester_id) 35 | .ok_or(CardDeleteError::NoRights) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Sergey Sova "] 3 | description = "Howtocards service backend" 4 | edition = "2018" 5 | homepage = "https://github.com/howtocards" 6 | license = "MIT" 7 | name = "howtocards-public-api" 8 | repository = "https://github.com/howtocards/backend" 9 | version = "0.4.3" 10 | 11 | [[bin]] 12 | name = "howtocards-public-api" 13 | path = "./src/main.rs" 14 | 15 | [dependencies] 16 | actix_base = { version = "0.7.4", package = "actix" } 17 | actix-web = "0.7.19" 18 | ammonia = "2.0.0" 19 | chrono = { version = "0.4.6", features = ["serde"] } 20 | diesel = { version = "1.4.2", features = ["postgres", "r2d2", "chrono", "serde_json"] } 21 | dotenv = "0.14.1" 22 | env_logger = "0.6.1" 23 | failure = "0.1.5" 24 | futures = "0.1.26" 25 | howtocards-db = { path = "../db" } 26 | lazy_static = "1.4.0" 27 | maplit = "1.0.1" 28 | md5 = "0.6.1" 29 | num_cpus = "1.10.0" 30 | regex = "1.3.1" 31 | serde = "1.0.90" 32 | serde_derive = "1.0.90" 33 | serde_json = "1.0.39" 34 | sha2 = "0.8.0" 35 | uuid = { version = "0.7.4", features = ["serde", "v4"] } 36 | reqwest = { version = "0.9.24" } 37 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: minimal 2 | 3 | os: 4 | - linux 5 | 6 | env: 7 | - CRATE_NAME=public-api 8 | - CRATE_NAME=internal-api 9 | 10 | services: 11 | - docker 12 | 13 | before_script: 14 | - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 15 | 16 | script: 17 | - ./build-image.sh $CRATE_NAME 18 | 19 | jobs: 20 | include: 21 | - stage: push latest 22 | if: branch = master AND type != pull_request 23 | script: 24 | - ./build-tag.sh public-api latest 25 | - ./build-tag.sh internal-api latest 26 | 27 | - stage: push dev 28 | if: branch = dev AND type != pull_request 29 | script: 30 | - ./build-tag.sh public-api dev 31 | - ./build-tag.sh internal-api dev 32 | 33 | - stage: deploy dev 34 | if: branch = dev AND type != pull_request 35 | script: 36 | - curl -X POST https://app.buddy.works/sergeysova/backend/pipelines/pipeline/173545/trigger-webhook?token=$BUDDY_TOKEN&revision=$TRAVIS_COMMIT 37 | 38 | - stage: push tag 39 | if: tag 40 | script: 41 | - ./build-tag.sh public-api $TRAVIS_TAG 42 | - ./build-tag.sh internal-api $TRAVIS_TAG 43 | -------------------------------------------------------------------------------- /public-api/src/handlers/cards/create.rs: -------------------------------------------------------------------------------- 1 | //! Create card 2 | 3 | use actix_base::prelude::*; 4 | use actix_web::*; 5 | 6 | use crate::app_state::DbExecutor; 7 | use crate::models::*; 8 | use crate::prelude::*; 9 | 10 | #[derive(Fail, Debug)] 11 | pub enum CardCreateError { 12 | /// When received empty `title` or/and `content` 13 | #[fail(display = "empty_title_or_content")] 14 | EmptyTitleContent, 15 | 16 | /// When diesel returns any error 17 | #[fail(display = "incorrect_form")] 18 | IncorrectForm, 19 | } 20 | 21 | impl_response_error_for!(CardCreateError as BadRequest); 22 | 23 | impl Message for CardNew { 24 | type Result = Result; 25 | } 26 | 27 | impl Handler for DbExecutor { 28 | type Result = Result; 29 | 30 | fn handle(&mut self, msg: CardNew, _: &mut Self::Context) -> Self::Result { 31 | if msg.title.len() > 2 { 32 | let card = CardNew { 33 | content: msg.content, 34 | ..msg 35 | }; 36 | 37 | Card::create(&self.conn, card, msg.author_id).ok_or(CardCreateError::IncorrectForm) 38 | } else { 39 | Err(CardCreateError::EmptyTitleContent) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public-api/src/prelude.rs: -------------------------------------------------------------------------------- 1 | pub use actix_base::prelude::*; 2 | pub use actix_web::*; 3 | pub use diesel::prelude::*; 4 | pub use failure::*; 5 | pub use futures::prelude::*; 6 | 7 | /// Local extensions for Result type 8 | pub trait ResultExt { 9 | /// Returns passed `err` wrapped to `Err()` if the result is `Err` 10 | /// 11 | /// Alias for `.map_err(|_| err)` 12 | /// 13 | /// # Examples 14 | /// 15 | /// Basic usage: 16 | /// 17 | /// ``` 18 | /// extern crate howtocards; 19 | /// use howtocards::prelude::*; 20 | /// fn foo(x: u32) -> Result { Err(x * 2) } 21 | /// 22 | /// #[derive(Debug, Eq, PartialEq)] 23 | /// enum ExErr { 24 | /// FooErr, 25 | /// } 26 | /// 27 | /// assert_eq!(foo(2).or_err(ExErr::FooErr).unwrap_err(), Err::(ExErr::FooErr).unwrap_err()); 28 | /// assert_eq!(foo(2).or_err(ExErr::FooErr).unwrap_err(), foo(2).map_err(|_| ExErr::FooErr).unwrap_err()); 29 | /// ``` 30 | fn or_err(self, err: E) -> Result; 31 | } 32 | 33 | impl ResultExt for Result { 34 | fn or_err(self, err: R) -> Result { 35 | self.map_err(|_| err) 36 | } 37 | } 38 | 39 | pub type FutRes = FutureResponse; 40 | -------------------------------------------------------------------------------- /db/src/schema.rs: -------------------------------------------------------------------------------- 1 | table! { 2 | cards (id) { 3 | id -> Int4, 4 | author_id -> Int4, 5 | title -> Varchar, 6 | created_at -> Nullable, 7 | updated_at -> Nullable, 8 | useful_for -> Int8, 9 | content -> Jsonb, 10 | content_for_search -> Varchar, 11 | preview_url -> Nullable, 12 | tags -> Array, 13 | } 14 | } 15 | 16 | table! { 17 | tokens (token) { 18 | token -> Varchar, 19 | user_id -> Int4, 20 | } 21 | } 22 | 23 | table! { 24 | useful_marks (card_id, user_id) { 25 | card_id -> Int4, 26 | user_id -> Int4, 27 | created_at -> Timestamp, 28 | } 29 | } 30 | 31 | table! { 32 | users (id) { 33 | id -> Int4, 34 | email -> Varchar, 35 | password -> Varchar, 36 | display_name -> Nullable, 37 | gravatar_email -> Nullable, 38 | username -> Varchar, 39 | } 40 | } 41 | 42 | joinable!(cards -> users (author_id)); 43 | joinable!(tokens -> users (user_id)); 44 | joinable!(useful_marks -> cards (card_id)); 45 | joinable!(useful_marks -> users (user_id)); 46 | 47 | allow_tables_to_appear_in_same_query!( 48 | cards, 49 | tokens, 50 | useful_marks, 51 | users, 52 | ); 53 | -------------------------------------------------------------------------------- /public-api/README.md: -------------------------------------------------------------------------------- 1 | # HowToCards 2 | 3 | ## Requirements 4 | 5 | - [Stable rust 1.37](https://rustup.rs) 6 | - Postgresql 11.5, requires [`libpq`](https://postgrespro.ru/docs/postgresql/9.6/libpq) 7 | 8 | ## Installation 9 | 10 | ### Configuration 11 | 12 | ```sh 13 | cp .env.sample .env 14 | # Edit and review .env file 15 | # Do not forget to create user and database in local postgres 16 | ``` 17 | 18 | ### Ubuntu 19 | 20 | ```sh 21 | curl https://sh.rustup.rs -sSf | sh 22 | sudo apt install gcc 23 | sudo apt install postgresql postgresql-contrib libpq-dev 24 | ``` 25 | 26 | ### Docker 27 | 28 | Using makefile 29 | 30 | ```sh 31 | make docker-init 32 | ``` 33 | 34 | or manual 35 | 36 | ```sh 37 | docker-compose up -d 38 | docker exec -i howtocards-public-api bash -c 'cd /app && diesel migration run' 39 | ``` 40 | 41 | ### Diesel CLI 42 | 43 | ```sh 44 | cargo install diesel_cli --no-default-features --features postgres 45 | ``` 46 | 47 | ## Build and run 48 | 49 | ```sh 50 | # Build production binary 51 | cargo build --release 52 | 53 | # Development 54 | cargo install cargo-watch 55 | cargo watch -x run 56 | ``` 57 | 58 | ## After pull, checkout, or db change 59 | 60 | ```sh 61 | diesel migration run 62 | ``` 63 | 64 | To revert migration run 65 | 66 | ```sh 67 | diesel migration revert 68 | ``` 69 | -------------------------------------------------------------------------------- /migrations/00000000000000_diesel_initial_setup/up.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | 6 | 7 | 8 | -- Sets up a trigger for the given table to automatically set a column called 9 | -- `updated_at` whenever the row is modified (unless `updated_at` was included 10 | -- in the modified columns) 11 | -- 12 | -- # Example 13 | -- 14 | -- ```sql 15 | -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); 16 | -- 17 | -- SELECT diesel_manage_updated_at('users'); 18 | -- ``` 19 | CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ 20 | BEGIN 21 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s 22 | FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ 27 | BEGIN 28 | IF ( 29 | NEW IS DISTINCT FROM OLD AND 30 | NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at 31 | ) THEN 32 | NEW.updated_at := current_timestamp; 33 | END IF; 34 | RETURN NEW; 35 | END; 36 | $$ LANGUAGE plpgsql; 37 | -------------------------------------------------------------------------------- /internal-api/src/main.rs: -------------------------------------------------------------------------------- 1 | use actix_rt::System; 2 | use actix_web::{middleware, web, App, HttpServer}; 3 | use diesel::r2d2::{self, ConnectionManager}; 4 | use diesel::PgConnection; 5 | use dotenv; 6 | 7 | mod answer; 8 | mod handlers; 9 | 10 | fn main() -> std::io::Result<()> { 11 | dotenv::dotenv().ok(); 12 | 13 | let listen = &std::env::var("LISTEN").unwrap_or("localhost:9002".to_string()); 14 | let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be specified"); 15 | 16 | std::env::set_var("RUST_LOG", "actix_server=info,actix_web=info"); 17 | env_logger::init(); 18 | 19 | let system = System::new("howtocards-internal-api"); 20 | 21 | let manager = ConnectionManager::::new(database_url); 22 | let pool = r2d2::Pool::builder() 23 | .build(manager) 24 | .expect("Failed to create pool."); 25 | 26 | HttpServer::new(move || { 27 | App::new() 28 | .data(pool.clone()) 29 | .data(web::JsonConfig::default().limit(1_048_576)) 30 | .wrap(middleware::Logger::default()) 31 | .service( 32 | web::resource("/preview/card/{card_id}") 33 | .route(web::post().to(handlers::card_set_preview)), 34 | ) 35 | }) 36 | .bind(listen)? 37 | .start(); 38 | 39 | println!("Running server on {}", listen); 40 | 41 | system.run() 42 | } 43 | -------------------------------------------------------------------------------- /public-api/src/handlers/account/create.rs: -------------------------------------------------------------------------------- 1 | //! Create account 2 | use actix_base::prelude::*; 3 | use actix_web::*; 4 | use uuid::Uuid; 5 | 6 | use crate::app_state::DbExecutor; 7 | use crate::consts; 8 | use crate::hasher; 9 | use crate::models::*; 10 | use crate::prelude::*; 11 | 12 | #[derive(Fail, Debug)] 13 | pub enum AccountCreateError { 14 | /// When email already exists in db 15 | #[fail(display = "email_already_exists")] 16 | EmailExists, 17 | } 18 | 19 | impl_response_error_for!(AccountCreateError as BadRequest); 20 | 21 | /// Account create message 22 | /// 23 | /// Should be sended to DbExecutor 24 | #[derive(Deserialize, Debug)] 25 | #[serde(rename_all = "camelCase")] 26 | pub struct AccountCreate { 27 | pub email: String, 28 | pub password: String, 29 | } 30 | 31 | impl Message for AccountCreate { 32 | type Result = Result; 33 | } 34 | 35 | impl Handler for DbExecutor { 36 | type Result = Result; 37 | 38 | fn handle(&mut self, msg: AccountCreate, _: &mut Self::Context) -> Self::Result { 39 | let new_account = UserNew { 40 | email: msg.email, 41 | password: hasher::hash_password(&msg.password, consts::SALT), 42 | username: format!("{}", Uuid::new_v4()), 43 | }; 44 | 45 | User::create(&self.conn, new_account).ok_or(AccountCreateError::EmailExists) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public-api/src/layer.rs: -------------------------------------------------------------------------------- 1 | //! Response layer 2 | 3 | #[derive(Serialize)] 4 | pub struct ErrorAnswer { 5 | ok: bool, 6 | error: String, 7 | } 8 | 9 | impl ErrorAnswer { 10 | pub fn new(error: String) -> Self { 11 | ErrorAnswer { ok: false, error } 12 | } 13 | } 14 | 15 | #[derive(Serialize)] 16 | pub struct SuccessAnswer { 17 | ok: bool, 18 | result: T, 19 | } 20 | 21 | impl SuccessAnswer { 22 | pub fn new(result: T) -> Self { 23 | SuccessAnswer { ok: true, result } 24 | } 25 | } 26 | 27 | macro_rules! impl_response_error_for { 28 | ($struct:ident as $response_status:ident) => { 29 | use crate::layer as lay; 30 | use actix_web; 31 | impl actix_web::error::ResponseError for $struct { 32 | fn error_response(&self) -> actix_web::HttpResponse { 33 | actix_web::HttpResponse::$response_status() 34 | .json(lay::ErrorAnswer::new(format!("{}", self))) 35 | } 36 | } 37 | }; 38 | } 39 | 40 | macro_rules! answer_success { 41 | ($response:ident, $value:expr) => {{ 42 | use crate::layer::SuccessAnswer; 43 | use actix_web::HttpResponse; 44 | HttpResponse::$response().json(SuccessAnswer::new($value)) 45 | }}; 46 | } 47 | 48 | macro_rules! answer_error { 49 | ($response:ident, $value:expr) => {{ 50 | use crate::layer::ErrorAnswer; 51 | use actix_web::HttpResponse; 52 | HttpResponse::$response().json(ErrorAnswer::new($value)) 53 | }}; 54 | } 55 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.pkg.github.com/howtocards/backend/builder:1.43 as build 2 | 3 | ENV USER="root" 4 | WORKDIR /app 5 | 6 | COPY ./Cargo.lock ./Cargo.toml ./ 7 | RUN cargo new public-api --bin --name howtocards-public-api && \ 8 | cargo new internal-api --bin --name howtocards-internal-api && \ 9 | cargo new db --lib --name howtocards-db 10 | COPY ./internal-api/Cargo.toml ./internal-api/Cargo.toml 11 | COPY ./public-api/Cargo.toml ./public-api/Cargo.toml 12 | COPY ./db/Cargo.toml ./db/Cargo.toml 13 | RUN cargo build --release 14 | 15 | RUN find ./target -type f -name *howtocards* | xargs rm -rf 16 | 17 | COPY ./diesel.toml ./diesel.toml 18 | COPY ./migrations ./migrations 19 | COPY ./db ./db 20 | COPY ./internal-api ./internal-api 21 | COPY ./public-api ./public-api 22 | 23 | ARG CRATE_NAME 24 | 25 | # RUN cargo test --release --verbose --package howtocards-$CRATE_NAME 26 | 27 | RUN cargo build --release --package howtocards-$CRATE_NAME 28 | 29 | # ---------------------------------------------------------------- 30 | 31 | FROM docker.pkg.github.com/howtocards/backend/start-tools:1 32 | 33 | ARG CRATE_NAME 34 | 35 | WORKDIR /app 36 | 37 | RUN touch .env 38 | 39 | COPY --from=build /out/diesel /bin/ 40 | COPY --from=build /app/target/release/howtocards-$CRATE_NAME ./server 41 | 42 | COPY --from=build /app/migrations ./migrations 43 | COPY --from=build /app/diesel.toml ./ 44 | COPY ./docker-entrypoint.sh ./entrypoint.sh 45 | 46 | RUN chmod +x entrypoint.sh && chmod +x server 47 | 48 | ENTRYPOINT ["/app/entrypoint.sh"] 49 | CMD ["/app/server"] 50 | -------------------------------------------------------------------------------- /internal-api/src/answer.rs: -------------------------------------------------------------------------------- 1 | use actix_http::{http::StatusCode, Response}; 2 | use actix_web::{Error, HttpRequest, Responder}; 3 | use futures::future::{ok, Ready}; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json; 6 | 7 | #[derive(Debug, Serialize, Deserialize)] 8 | #[serde(untagged)] 9 | pub enum Answer { 10 | Success { ok: bool, result: S }, 11 | Failed { ok: bool, error: E }, 12 | Unexpected { ok: bool, error: String }, 13 | } 14 | 15 | impl Answer { 16 | pub fn ok(result: S) -> Self { 17 | Answer::Success { ok: true, result } 18 | } 19 | 20 | pub fn fail(error: E) -> Self { 21 | Answer::Failed { ok: false, error } 22 | } 23 | 24 | pub fn unexpected(message: String) -> Self { 25 | Answer::Unexpected { 26 | ok: false, 27 | error: message, 28 | } 29 | } 30 | } 31 | 32 | impl Responder for Answer { 33 | type Error = Error; 34 | type Future = Ready>; 35 | 36 | #[inline] 37 | fn respond_to(self, _: &HttpRequest) -> Self::Future { 38 | let status = match self { 39 | Answer::Success { .. } => StatusCode::OK, 40 | Answer::Failed { .. } => StatusCode::BAD_REQUEST, 41 | Answer::Unexpected { .. } => StatusCode::INTERNAL_SERVER_ERROR, 42 | }; 43 | 44 | ok(Response::build(status) 45 | .content_type("application/json; charset=utf-8") 46 | .body(serde_json::to_string(&self).expect("Unable to serialize answer"))) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /public-api/src/models/useful_mark.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{Card, User}; 2 | use crate::time; 3 | use chrono::NaiveDateTime; 4 | use diesel; 5 | use diesel::prelude::*; 6 | use howtocards_db::schema::useful_marks; 7 | 8 | #[derive(Debug, Insertable, Queryable, Associations)] 9 | #[belongs_to(User)] 10 | #[belongs_to(Card)] 11 | pub struct UsefulMark { 12 | pub user_id: i32, 13 | pub card_id: i32, 14 | pub created_at: NaiveDateTime, 15 | } 16 | 17 | impl UsefulMark { 18 | pub fn new(card_id: i32, user_id: i32) -> Self { 19 | UsefulMark { 20 | card_id, 21 | user_id, 22 | created_at: time::now(), 23 | } 24 | } 25 | 26 | pub fn create(conn: &PgConnection, card_id: i32, user_id: i32) -> Option { 27 | diesel::insert_into(useful_marks::table) 28 | .values(&Self::new(card_id, user_id)) 29 | .get_result(conn) 30 | .ok() 31 | } 32 | 33 | pub fn delete(conn: &PgConnection, card_id: i32, user_id: i32) -> Option { 34 | let target = useful_marks::table 35 | .filter(useful_marks::card_id.eq(card_id)) 36 | .filter(useful_marks::user_id.eq(user_id)); 37 | 38 | diesel::delete(target).execute(conn).ok() 39 | } 40 | 41 | pub fn count_for_card(conn: &PgConnection, card_id: i32) -> i64 { 42 | use diesel::dsl::count_star; 43 | 44 | useful_marks::table 45 | .filter(useful_marks::card_id.eq(card_id)) 46 | .select(count_star()) 47 | .first(conn) 48 | .unwrap_or(0) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /public-api/src/routes/search.rs: -------------------------------------------------------------------------------- 1 | //! /search 2 | 3 | use crate::app_state::AppState; 4 | use crate::auth::AuthOptional; 5 | use crate::handlers::search::*; 6 | use crate::models::*; 7 | use crate::prelude::*; 8 | use actix_web::{Query, State}; 9 | 10 | #[derive(Deserialize)] 11 | pub struct SearchQuery { 12 | q: String, 13 | page: Option, 14 | count: Option, 15 | } 16 | 17 | impl SearchQuery { 18 | fn to_pagination(&self) -> Pagination { 19 | let def: Pagination = Default::default(); 20 | 21 | Pagination { 22 | page: self.page.unwrap_or(def.page), 23 | count: self.count.unwrap_or(def.count), 24 | } 25 | } 26 | } 27 | 28 | /// GET /search/? 29 | pub fn search(auth: AuthOptional, state: State, query: Query) -> FutRes { 30 | #[derive(Serialize)] 31 | struct R { 32 | cards: Vec, 33 | } 34 | 35 | state 36 | .pg 37 | .send(SearchRequest { 38 | requester_id: auth.user.map(|u| u.id), 39 | pagination: query.to_pagination(), 40 | query: query.q.clone(), 41 | }) 42 | .from_err() 43 | .and_then(|cards| { 44 | Ok(answer_success!( 45 | Ok, 46 | R { 47 | cards: cards.unwrap_or_default() 48 | } 49 | )) 50 | }) 51 | .responder() 52 | } 53 | 54 | #[inline] 55 | pub fn scope(scope: Scope) -> Scope { 56 | scope.resource("/", |r| { 57 | r.get().with(self::search); 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /public-api/src/hasher.rs: -------------------------------------------------------------------------------- 1 | //! Hashing utilites 2 | 3 | use sha2::{Digest, Sha256}; 4 | use std::fmt::Display; 5 | 6 | /// Hash string with sha256 7 | /// 8 | /// # Examples 9 | /// 10 | /// ``` 11 | /// # extern crate howtocards; 12 | /// use howtocards::hasher::hash_string; 13 | /// 14 | /// assert_eq!(hash_string("Foo"), "1CBEC737F863E4922CEE63CC2EBBFAAFCD1CFF8B790D8CFD2E6A5D550B648AFA".to_string()); 15 | /// ``` 16 | pub fn hash_string>(value: S) -> String { 17 | let mut hasher = Sha256::default(); 18 | 19 | hasher.input(value.as_ref()); 20 | 21 | hasher 22 | .result() 23 | .iter() 24 | .map(|b| format!("{:02X}", b)) 25 | .collect() 26 | } 27 | 28 | /// Hash password with custom salt 29 | pub fn hash_password(password: P, salt: S) -> String 30 | where 31 | P: Display, 32 | S: Display, 33 | { 34 | hash_string(format!("{}${}", password, salt)) 35 | } 36 | 37 | mod test { 38 | #[allow(unused_imports)] 39 | use super::{hash_password, hash_string}; 40 | 41 | #[test] 42 | fn hash_string_should_get_different_results_for_different_input() { 43 | assert_ne!(hash_string("Foo"), hash_string("Foo1")); 44 | assert_ne!(hash_string("Foo"), hash_string("foo")); 45 | } 46 | 47 | #[test] 48 | fn hash_string_should_get_equal_results_for_equal_input() { 49 | assert_eq!(hash_string("Foo"), hash_string("Foo")); 50 | assert_eq!(hash_string("Bar"), hash_string("Bar")); 51 | } 52 | 53 | #[test] 54 | fn hash_password_use_hash_string() { 55 | assert_eq!(hash_string("Foo$Bar"), hash_password("Foo", "Bar")); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal-api/src/handlers.rs: -------------------------------------------------------------------------------- 1 | use crate::answer::Answer; 2 | use actix_web::{web, Error as AWError}; 3 | use diesel::r2d2::ConnectionManager; 4 | use diesel::PgConnection; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use howtocards_db::{diesel, schema}; 8 | 9 | pub type PgPool = r2d2::Pool>; 10 | 11 | #[derive(Debug, Deserialize)] 12 | #[serde(rename_all = "camelCase")] 13 | pub struct SetPreviewBody { 14 | snapshot: String, 15 | screenshot: String, 16 | } 17 | 18 | #[derive(Debug, Deserialize)] 19 | pub struct CardPath { 20 | card_id: u32, 21 | } 22 | 23 | #[derive(Debug, Serialize)] 24 | pub struct Example { 25 | id: u32, 26 | } 27 | 28 | #[derive(Debug, Serialize)] 29 | pub enum SetPreviewError { 30 | DatabaseFailure, 31 | } 32 | 33 | impl Into> for r2d2::Error { 34 | fn into(self) -> Answer { 35 | Answer::unexpected(format!("Database error: {}", self.to_string())) 36 | } 37 | } 38 | 39 | pub async fn card_set_preview( 40 | body: web::Json, 41 | poll: web::Data, 42 | path: web::Path, 43 | ) -> Result, AWError> { 44 | let conn = poll.get().expect("failed to connect"); 45 | 46 | use diesel::*; 47 | use schema::cards::dsl::*; 48 | 49 | let target = cards.filter(id.eq(path.card_id as i32)); 50 | 51 | let query = diesel::update(target).set(preview_url.eq(Some(body.screenshot.to_string()))); 52 | 53 | match query.execute(&conn) { 54 | Err(_) => Ok(Answer::fail(SetPreviewError::DatabaseFailure)), 55 | Ok(_) => Ok(Answer::ok(Example { id: path.card_id })), 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /public-api/src/handlers/account/login.rs: -------------------------------------------------------------------------------- 1 | //! Session create 2 | use actix_base::prelude::*; 3 | use actix_web::*; 4 | 5 | use crate::app_state::DbExecutor; 6 | use crate::consts; 7 | use crate::hasher; 8 | use crate::models::*; 9 | use crate::prelude::*; 10 | 11 | #[derive(Debug, Fail, Serialize)] 12 | pub enum SessionCreateError { 13 | /// When user id not found in db 14 | #[fail(display = "user_not_found")] 15 | UserNotFound, 16 | 17 | /// When happened something terrible like session string already exists 18 | #[fail(display = "cant_create_session")] 19 | TokenInsertFail, 20 | } 21 | 22 | impl_response_error_for!(SessionCreateError as BadRequest); 23 | 24 | /// Pass data to router 25 | pub struct SessionToken { 26 | pub token: String, 27 | pub user: User, 28 | } 29 | 30 | /// Session create message 31 | /// 32 | /// Should be sended to DbExecutor 33 | #[derive(Deserialize, Debug)] 34 | #[serde(rename_all = "camelCase")] 35 | pub struct SessionCreate { 36 | pub email: String, 37 | pub password: String, 38 | } 39 | 40 | impl Message for SessionCreate { 41 | type Result = Result; 42 | } 43 | 44 | impl Handler for DbExecutor { 45 | type Result = Result; 46 | 47 | fn handle(&mut self, msg: SessionCreate, _: &mut Self::Context) -> Self::Result { 48 | let credentials = Credentials { 49 | email: msg.email, 50 | password: hasher::hash_password(&msg.password, consts::SALT), 51 | }; 52 | 53 | let user = User::find_by_credentials(&self.conn, credentials) 54 | .ok_or(SessionCreateError::UserNotFound)?; 55 | 56 | let token = Token::create(&self.conn, user.id) 57 | .ok_or(SessionCreateError::TokenInsertFail)? 58 | .token; 59 | 60 | Ok(SessionToken { token, user }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/rust,windows,linux,macos 2 | # Edit at https://www.gitignore.io/?templates=rust,windows,linux,macos 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | # Thumbnails 29 | ._* 30 | 31 | # Files that might appear in the root of a volume 32 | .DocumentRevisions-V100 33 | .fseventsd 34 | .Spotlight-V100 35 | .TemporaryItems 36 | .Trashes 37 | .VolumeIcon.icns 38 | .com.apple.timemachine.donotpresent 39 | 40 | # Directories potentially created on remote AFP share 41 | .AppleDB 42 | .AppleDesktop 43 | Network Trash Folder 44 | Temporary Items 45 | .apdisk 46 | 47 | ### Rust ### 48 | # Generated by Cargo 49 | # will have compiled files and executables 50 | target 51 | 52 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 53 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 54 | Cargo.lock 55 | 56 | # These are backup files generated by rustfmt 57 | **/*.rs.bk 58 | 59 | ### Windows ### 60 | # Windows thumbnail cache files 61 | Thumbs.db 62 | Thumbs.db:encryptable 63 | ehthumbs.db 64 | ehthumbs_vista.db 65 | 66 | # Dump file 67 | *.stackdump 68 | 69 | # Folder config file 70 | [Dd]esktop.ini 71 | 72 | # Recycle Bin used on file shares 73 | $RECYCLE.BIN/ 74 | 75 | # Windows Installer files 76 | *.cab 77 | *.msi 78 | *.msix 79 | *.msm 80 | *.msp 81 | 82 | # Windows shortcuts 83 | *.lnk 84 | 85 | # End of https://www.gitignore.io/api/rust,windows,linux,macos 86 | 87 | tmp 88 | *.out 89 | .idea 90 | .env 91 | -------------------------------------------------------------------------------- /public-api/src/handlers/account/update.rs: -------------------------------------------------------------------------------- 1 | //! Update account settings 2 | use actix_base::{Handler, Message}; 3 | use failure::Fail; 4 | 5 | use crate::app_state::DbExecutor; 6 | use crate::models::user::{UpdateError, User}; 7 | 8 | #[derive(Fail, Debug)] 9 | pub enum AccountUpdateError { 10 | #[fail(display = "nothing_to_update")] 11 | NothingToUpdate, 12 | 13 | #[fail(display = "failed_to_update")] 14 | Failed, 15 | 16 | #[fail(display = "username_empty")] 17 | UsernameEmpty, 18 | 19 | #[fail(display = "username_incorrect")] 20 | UsernameIncorrect, 21 | 22 | #[fail(display = "username_taken")] 23 | UsernameTaken, 24 | } 25 | 26 | impl_response_error_for!(AccountUpdateError as BadRequest); 27 | 28 | #[derive(Deserialize, Debug)] 29 | #[serde(rename_all = "camelCase")] 30 | pub struct AccountUpdate { 31 | pub requester_id: i32, 32 | pub display_name: String, 33 | pub gravatar_email: String, 34 | pub username: String, 35 | } 36 | 37 | impl Message for AccountUpdate { 38 | type Result = Result; 39 | } 40 | 41 | impl Handler for DbExecutor { 42 | type Result = Result; 43 | 44 | fn handle(&mut self, msg: AccountUpdate, _: &mut Self::Context) -> Self::Result { 45 | if msg.username.trim().is_empty() { 46 | Err(AccountUpdateError::UsernameEmpty) 47 | } else if !crate::validators::check_username(msg.username.as_str()) { 48 | Err(AccountUpdateError::UsernameIncorrect) 49 | } else { 50 | User::update( 51 | &self.conn, 52 | msg.requester_id, 53 | msg.display_name, 54 | msg.gravatar_email, 55 | msg.username, 56 | ) 57 | .map_err(|error| match error { 58 | UpdateError::UsernameTaken => AccountUpdateError::UsernameTaken, 59 | _ => AccountUpdateError::Failed, 60 | }) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /public-api/src/handlers/cards/toggle_useful_mark.rs: -------------------------------------------------------------------------------- 1 | //! Mark card as useful 2 | 3 | use actix_base::prelude::*; 4 | use actix_web::*; 5 | 6 | use crate::app_state::DbExecutor; 7 | use crate::models::*; 8 | use crate::prelude::*; 9 | 10 | /// May fail when SetMarkCardUseful sended to DbExecutor 11 | #[derive(Fail, Debug)] 12 | pub enum ToggleUsefulMarkError { 13 | #[fail(display = "user_not_found")] 14 | UserNotFound, 15 | 16 | #[fail(display = "card_not_found")] 17 | CardNotFound, 18 | } 19 | 20 | impl_response_error_for!(ToggleUsefulMarkError as BadRequest); 21 | 22 | /// Mark/Unmark card useful 23 | pub struct ToggleUsefulMark { 24 | pub card_id: i32, 25 | pub requester_id: i32, 26 | pub set_is_useful: bool, 27 | } 28 | 29 | impl Message for ToggleUsefulMark { 30 | type Result = Result; 31 | } 32 | 33 | impl Handler for DbExecutor { 34 | type Result = Result; 35 | 36 | fn handle(&mut self, msg: ToggleUsefulMark, _ctx: &mut Self::Context) -> Self::Result { 37 | // TODO refactor to much less requests to db 38 | 39 | // Check if cards exists 40 | let card = Card::find_by_id(&self.conn, msg.card_id, msg.requester_id) 41 | .ok_or(ToggleUsefulMarkError::CardNotFound)?; 42 | 43 | // Check if user exists 44 | let _user = User::find_by_id(&self.conn, msg.requester_id) 45 | .ok_or(ToggleUsefulMarkError::UserNotFound)?; 46 | 47 | if msg.set_is_useful { 48 | UsefulMark::create(&self.conn, msg.card_id, msg.requester_id); 49 | } else { 50 | UsefulMark::delete(&self.conn, msg.card_id, msg.requester_id); 51 | } 52 | 53 | let useful_count: i64 = UsefulMark::count_for_card(&self.conn, msg.card_id); 54 | 55 | let new_card = 56 | Card::update_useful_for(&self.conn, msg.card_id, useful_count, msg.requester_id) 57 | .unwrap_or(card); 58 | 59 | Ok(new_card) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /public-api/src/handlers/search/mod.rs: -------------------------------------------------------------------------------- 1 | //! Search 2 | 3 | use actix_base::prelude::*; 4 | use diesel::*; 5 | 6 | use crate::app_state::DbExecutor; 7 | use crate::models::*; 8 | use howtocards_db::schema::cards; 9 | 10 | #[allow(dead_code)] 11 | pub enum Sort { 12 | RecentCreated, 13 | MostUseful, 14 | } 15 | 16 | pub struct SearchRequest { 17 | pub requester_id: Option, 18 | pub query: String, 19 | pub pagination: Pagination, 20 | } 21 | 22 | impl Message for SearchRequest { 23 | type Result = Option>; 24 | } 25 | 26 | impl Handler for DbExecutor { 27 | type Result = Option>; 28 | 29 | fn handle(&mut self, msg: SearchRequest, _ctx: &mut Self::Context) -> Self::Result { 30 | let mut query = cards::table 31 | .select(Card::all_columns(msg.requester_id.unwrap_or(-1))) 32 | .into_boxed(); 33 | 34 | let query_for_title = msg.query.replace(" ", "%"); 35 | let query_string = format!("%{}%", query_for_title); 36 | 37 | query = query.filter( 38 | cards::content_for_search 39 | .ilike::(query_string.clone()) 40 | .or(cards::title.ilike::(query_string)) 41 | .or(cards::title.eq::(query_for_title)), 42 | ); 43 | 44 | let cards = query.load::(&self.conn); 45 | cards.ok() 46 | } 47 | } 48 | 49 | pub const DEFAULT_PAGINATION_COUNT: u32 = 10; 50 | pub const MAXIMUM_PAGINATION_COUNT: u32 = 200; 51 | 52 | pub struct Pagination { 53 | pub page: u32, 54 | pub count: u32, 55 | } 56 | 57 | impl Default for Pagination { 58 | fn default() -> Self { 59 | Pagination { 60 | page: 1, 61 | count: DEFAULT_PAGINATION_COUNT, 62 | } 63 | } 64 | } 65 | 66 | #[allow(dead_code)] 67 | impl Pagination { 68 | fn offset(&self) -> u32 { 69 | self.page * self.limit() - self.limit() 70 | } 71 | 72 | fn limit(&self) -> u32 { 73 | if self.count < MAXIMUM_PAGINATION_COUNT { 74 | self.count 75 | } else { 76 | MAXIMUM_PAGINATION_COUNT 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /public-api/src/handlers/cards/edit.rs: -------------------------------------------------------------------------------- 1 | //! Edit existing card 2 | 3 | use actix_base::prelude::*; 4 | use actix_web::*; 5 | use serde_json::Value; 6 | 7 | use crate::app_state::DbExecutor; 8 | use crate::layer::ErrorAnswer; 9 | use crate::models::*; 10 | use crate::prelude::*; 11 | 12 | #[derive(Fail, Debug)] 13 | pub enum CardEditError { 14 | /// When card with id not found 15 | #[fail(display = "card_not_found")] 16 | NotFound, 17 | 18 | /// When diesel returns any error 19 | #[fail(display = "incorrect_form")] 20 | IncorrectForm, 21 | 22 | /// When user is not author of the card 23 | #[fail(display = "no_acess")] 24 | NoRights, 25 | } 26 | 27 | impl ResponseError for CardEditError { 28 | fn error_response(&self) -> HttpResponse { 29 | match self { 30 | CardEditError::NotFound => HttpResponse::NotFound(), 31 | CardEditError::IncorrectForm => HttpResponse::BadRequest(), 32 | CardEditError::NoRights => HttpResponse::Forbidden(), 33 | } 34 | .json(ErrorAnswer::new(format!("{}", self))) 35 | } 36 | } 37 | 38 | pub struct CardEdit { 39 | pub card_id: u32, 40 | /// User id who requested edit of card 41 | pub requester_id: i32, 42 | pub title: Option, 43 | pub content: Option, 44 | } 45 | 46 | impl Message for CardEdit { 47 | type Result = Result; 48 | } 49 | 50 | impl Handler for DbExecutor { 51 | type Result = Result; 52 | 53 | fn handle(&mut self, msg: CardEdit, _ctx: &mut Self::Context) -> Self::Result { 54 | let found = Card::find_by_id(&self.conn, msg.card_id as i32, msg.requester_id) 55 | .ok_or(CardEditError::NotFound)?; 56 | 57 | if found.author_id != msg.requester_id { 58 | Err(CardEditError::NoRights)?; 59 | } 60 | 61 | let new_content = msg.content.unwrap_or(found.content); 62 | 63 | Card::update( 64 | &self.conn, 65 | msg.card_id as i32, 66 | msg.requester_id, 67 | msg.title.unwrap_or(found.title), 68 | new_content, 69 | ) 70 | .ok_or(CardEditError::IncorrectForm) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /public-api/src/views.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | 3 | /// Serialization for User model 4 | /// Without password field 5 | #[derive(Serialize, Debug)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct EncodableUserPrivate { 8 | pub display_name: Option, 9 | pub email: String, 10 | pub id: i32, 11 | pub avatar: String, 12 | pub username: String, 13 | } 14 | 15 | /// Serialization for User model 16 | /// Same as Private except without email 17 | #[derive(Serialize, Debug)] 18 | #[serde(rename_all = "camelCase")] 19 | pub struct EncodableUserPublic { 20 | pub display_name: Option, 21 | pub id: i32, 22 | pub avatar: String, 23 | pub username: String, 24 | } 25 | 26 | /// User settings to communicate with frontend 27 | #[derive(Debug, Serialize)] 28 | #[serde(rename_all = "camelCase")] 29 | pub struct UserSettings { 30 | pub display_name: Option, 31 | pub gravatar_email: Option, 32 | pub current_email: Option, 33 | pub username: String, 34 | } 35 | 36 | #[derive(Debug, Serialize)] 37 | #[serde(rename_all = "camelCase")] 38 | pub struct CardMeta { 39 | pub title: String, 40 | pub description: String, 41 | pub id: i32, 42 | pub created_at: Option, 43 | pub updated_at: Option, 44 | pub preview_url: Option, 45 | } 46 | 47 | #[derive(Debug, Serialize)] 48 | #[serde(rename_all = "camelCase")] 49 | pub struct Permissions { 50 | pub is_useful: bool, 51 | pub can_edit: bool, 52 | } 53 | 54 | #[derive(Debug, Serialize)] 55 | #[serde(rename_all = "camelCase")] 56 | pub struct Card { 57 | pub id: i32, 58 | pub author_id: i32, 59 | pub title: String, 60 | pub content: CardContent, 61 | pub created_at: NaiveDateTime, 62 | pub updated_at: NaiveDateTime, 63 | /// Count of users, that added card to its library 64 | pub useful_for: i64, 65 | pub permissions: Permissions, 66 | pub tags: Vec, 67 | pub preview_url: Option, 68 | } 69 | 70 | #[derive(Debug, Serialize)] 71 | #[serde(untagged)] 72 | pub enum CardContent { 73 | /// Source returned only if card is not rendered yet 74 | Source(serde_json::Value), 75 | Rendered(String), 76 | } 77 | -------------------------------------------------------------------------------- /public-api/src/auth_token.rs: -------------------------------------------------------------------------------- 1 | //! Authentication token parsing 2 | 3 | use actix_web::middleware::identity::{Identity, IdentityPolicy}; 4 | use actix_web::middleware::Response; 5 | use actix_web::{Error, HttpMessage, HttpResponse}; 6 | use futures::future::{ok as fut_ok, FutureResult}; 7 | use std::rc::Rc; 8 | use std::str::FromStr; 9 | 10 | use crate::app_state::{AppState, Req}; 11 | 12 | #[derive(Debug, Clone, PartialEq, Eq)] 13 | pub enum ParseAuthorizationError { 14 | EmptyContent, 15 | InvalidChunksCount, 16 | } 17 | 18 | #[derive(Debug, PartialEq)] 19 | pub struct Authorization { 20 | prefix: String, 21 | value: String, 22 | } 23 | 24 | impl FromStr for Authorization { 25 | type Err = ParseAuthorizationError; 26 | 27 | fn from_str(source: &str) -> Result { 28 | if source.len() < 3 { 29 | Err(ParseAuthorizationError::EmptyContent) 30 | } else { 31 | let chunks: Vec<&str> = source.split(' ').collect(); 32 | 33 | if chunks.len() != 2 { 34 | Err(ParseAuthorizationError::InvalidChunksCount) 35 | } else { 36 | Ok(Authorization { 37 | prefix: chunks[0].to_string(), 38 | value: chunks[1].to_string(), 39 | }) 40 | } 41 | } 42 | } 43 | } 44 | 45 | pub struct TokenIdentityInner { 46 | prefix: String, 47 | } 48 | 49 | impl TokenIdentityInner { 50 | fn new(prefix: String) -> Self { 51 | TokenIdentityInner { prefix } 52 | } 53 | 54 | fn load(&self, req: &Req) -> Option { 55 | let auth_header = req.headers().get("Authorization")?.to_str().ok()?; 56 | let auth: Authorization = auth_header.parse().ok()?; 57 | 58 | if auth.prefix.eq(&self.prefix) { 59 | Some(auth.value.to_string()) 60 | } else { 61 | None 62 | } 63 | } 64 | } 65 | 66 | pub struct TokenIdentity { 67 | identity: Option, 68 | // inner: Rc, 69 | } 70 | 71 | impl Identity for TokenIdentity { 72 | fn identity(&self) -> Option<&str> { 73 | self.identity.as_ref().map(|s| s.as_ref()) 74 | } 75 | 76 | fn remember(&mut self, value: String) { 77 | self.identity = Some(value); 78 | } 79 | 80 | fn forget(&mut self) { 81 | self.identity = None; 82 | } 83 | 84 | fn write(&mut self, resp: HttpResponse) -> Result { 85 | Ok(Response::Done(resp)) 86 | } 87 | } 88 | 89 | pub struct TokenIdentityPolicy(Rc); 90 | 91 | impl TokenIdentityPolicy { 92 | pub fn new(prefix: String) -> Self { 93 | TokenIdentityPolicy(Rc::new(TokenIdentityInner::new(prefix))) 94 | } 95 | } 96 | 97 | impl IdentityPolicy for TokenIdentityPolicy { 98 | type Identity = TokenIdentity; 99 | type Future = FutureResult; 100 | 101 | fn from_request(&self, req: &Req) -> Self::Future { 102 | let identity = self.0.load(req); 103 | 104 | fut_ok(TokenIdentity { 105 | identity, 106 | // inner: Rc::clone(&self.0), 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /public-api/src/main.rs: -------------------------------------------------------------------------------- 1 | #![doc(html_logo_url = "https://avatars0.githubusercontent.com/u/38739163?s=200&v=4")] 2 | #![allow(proc_macro_derive_resolution_fallback)] 3 | 4 | #[macro_use] 5 | extern crate serde_derive; 6 | #[macro_use] 7 | extern crate diesel; 8 | #[macro_use] 9 | extern crate lazy_static; 10 | // #[macro_use] 11 | extern crate actix_base; 12 | extern crate failure; 13 | extern crate maplit; 14 | 15 | use actix_web::middleware::identity::IdentityService; 16 | use actix_web::{http, middleware, server, App}; 17 | use diesel::PgConnection; 18 | use std::env; 19 | 20 | mod app_state; 21 | mod auth; 22 | mod auth_token; 23 | mod consts; 24 | mod gravatar; 25 | mod hasher; 26 | mod prelude; 27 | mod time; 28 | mod validators; 29 | #[macro_use] 30 | mod layer; 31 | mod handlers; 32 | mod models; 33 | mod preview; 34 | pub mod routes; 35 | mod slate; 36 | mod views; 37 | 38 | use self::app_state::AppState; 39 | use self::prelude::*; 40 | 41 | fn main() { 42 | if let Err(e) = run() { 43 | eprintln!("Error: {}", e); 44 | } 45 | } 46 | 47 | #[derive(Fail, Debug)] 48 | enum StartErr { 49 | #[fail(display = "expected DATABASE_URL env var")] 50 | DbExpected, 51 | 52 | #[fail(display = "expected PREVIEW_QUEUE_URL env var")] 53 | PreviewQueueUrl, 54 | 55 | #[fail(display = "check if .env file exists and it's correct")] 56 | DotEnvFail, 57 | } 58 | 59 | fn run() -> Result<(), failure::Error> { 60 | dotenv::dotenv().or_err(StartErr::DotEnvFail)?; 61 | let db_url = env::var("DATABASE_URL").or_err(StartErr::DbExpected)?; 62 | let preview_queue_url = env::var("PREVIEW_QUEUE_URL").or_err(StartErr::PreviewQueueUrl)?; 63 | 64 | create_server(db_url, preview_queue_url)?; 65 | 66 | Ok(()) 67 | } 68 | 69 | fn create_server(db_url: String, preview_queue_url: String) -> Result<(), failure::Error> { 70 | env_logger::init(); 71 | use self::app_state::DbExecutor; 72 | 73 | let cpus = num_cpus::get(); 74 | let system = System::new("htc-server"); 75 | 76 | let pg = SyncArbiter::start(cpus, move || DbExecutor::new(establish_connection(&db_url))); 77 | 78 | let server_creator = move || { 79 | let state = AppState { 80 | pg: pg.clone(), 81 | preview_queue_url: preview_queue_url.clone(), 82 | }; 83 | 84 | App::with_state(state) 85 | .middleware(middleware::Logger::default()) 86 | .middleware( 87 | middleware::cors::Cors::build() 88 | // .allowed_origin("http://127.0.0.1:9000/") 89 | // .send_wildcard() 90 | .supports_credentials() 91 | .allowed_methods(vec![ 92 | "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", 93 | ]) 94 | .allowed_headers(vec![http::header::AUTHORIZATION, http::header::ACCEPT]) 95 | .allowed_headers(vec![http::header::CONTENT_TYPE]) 96 | .max_age(3600) 97 | .finish(), 98 | ) 99 | .middleware(IdentityService::new(auth_token::TokenIdentityPolicy::new( 100 | "bearer".into(), 101 | ))) 102 | .scope("/api", routes::scope) 103 | }; 104 | 105 | let app = server::new(server_creator) 106 | .workers(cpus) 107 | .bind("0.0.0.0:9000") 108 | .expect("Can not bind to 127.0.0.1:9000"); 109 | 110 | println!("Server started on http://127.0.0.1:9000"); 111 | app.start(); 112 | system.run(); 113 | 114 | Ok(()) 115 | } 116 | 117 | #[inline] 118 | fn establish_connection(db_url: &str) -> PgConnection { 119 | use diesel::prelude::*; 120 | 121 | PgConnection::establish(&db_url).unwrap_or_else(|_| panic!("Error connecting to {}", db_url)) 122 | } 123 | -------------------------------------------------------------------------------- /public-api/src/auth.rs: -------------------------------------------------------------------------------- 1 | //! Authentication extractors 2 | 3 | use actix_web::middleware::identity::RequestIdentity; 4 | use actix_web::{FromRequest, HttpRequest, HttpResponse, ResponseError}; 5 | use failure::*; 6 | use futures::prelude::*; 7 | 8 | use crate::app_state::AppState; 9 | use crate::handlers::account::session_fetch::AccountSessionFetch; 10 | use crate::models::User; 11 | use crate::prelude::ResultExt; 12 | 13 | /// Describe error that shows to user 14 | #[derive(Serialize, Deserialize, Default, Debug)] 15 | struct ApiErrorResponse { 16 | error: String, 17 | ok: bool, 18 | } 19 | 20 | impl ApiErrorResponse { 21 | pub fn from_fail(fail: &impl Fail) -> Self { 22 | let mut list = vec![]; 23 | 24 | for cause in Fail::iter_chain(fail) { 25 | let msg = cause.to_string(); 26 | if !list.contains(&msg) { 27 | list.push(msg); 28 | } 29 | } 30 | 31 | ApiErrorResponse { 32 | ok: false, 33 | error: list.remove(0), 34 | } 35 | } 36 | } 37 | 38 | /// Describe specific error of auth 39 | #[derive(Fail, Debug)] 40 | pub enum AuthError { 41 | /// When received token from user is invalid 42 | #[fail(display = "invalid_token")] 43 | InvalidToken, 44 | 45 | /// When received token not found in database 46 | #[fail(display = "unknown_token")] 47 | UnknownToken, 48 | 49 | /// When user don't sended token 50 | #[fail(display = "missing_header")] 51 | MissingHeader, 52 | } 53 | 54 | impl ResponseError for AuthError { 55 | fn error_response(&self) -> HttpResponse { 56 | HttpResponse::Unauthorized().json(&ApiErrorResponse::from_fail(self)) 57 | } 58 | } 59 | 60 | /// Extractor to handle only authenticated requests 61 | /// 62 | /// Respond with [`AuthError`] if income request without auth 63 | /// 64 | /// # Example 65 | /// 66 | /// ```rust 67 | /// # extern crate howtocards; 68 | /// # extern crate actix_web; 69 | /// # use howtocards::auth::*; 70 | /// # use actix_web::*; 71 | /// fn example(auth: Auth) -> impl Responder { 72 | /// let user = auth.user; 73 | /// 74 | /// "example response".to_string() 75 | /// } 76 | /// ``` 77 | /// [`AuthError`]: enum.AuthError.html 78 | #[derive(Debug)] 79 | pub struct Auth { 80 | pub user: User, 81 | } 82 | 83 | impl FromRequest for Auth { 84 | type Config = (); 85 | type Result = Result; 86 | 87 | fn from_request(req: &HttpRequest, _cfg: &Self::Config) -> Self::Result { 88 | let id = req.identity().ok_or(AuthError::InvalidToken)?.to_string(); 89 | 90 | req.state() 91 | .pg 92 | .send(AccountSessionFetch { token: id }) 93 | .wait() 94 | .or_err(AuthError::UnknownToken) 95 | .and_then(|user| user.ok_or(AuthError::InvalidToken)) 96 | .map(|user| Auth { user }) 97 | } 98 | } 99 | 100 | /// Extractor to handle optional authentication 101 | /// 102 | /// Respond with [`AuthError`] if income request without auth 103 | /// 104 | /// # Example 105 | /// 106 | /// ``` 107 | /// # extern crate howtocards; 108 | /// # extern crate actix_web; 109 | /// # use howtocards::auth::*; 110 | /// # use actix_web::*; 111 | /// fn example(auth: AuthOptional) -> impl Responder { 112 | /// if let Some(user) = auth.user { 113 | /// println!("Hello {}", user.email); 114 | /// } 115 | /// "ExampleResult".to_string() 116 | /// } 117 | /// ``` 118 | /// [`AuthError`]: enum.AuthError.html 119 | #[derive(Debug)] 120 | pub struct AuthOptional { 121 | pub user: Option, 122 | } 123 | 124 | impl FromRequest for AuthOptional { 125 | type Config = (); 126 | type Result = Result; 127 | 128 | fn from_request(req: &HttpRequest, cfg: &Self::Config) -> Self::Result { 129 | Ok(AuthOptional { 130 | user: Auth::from_request(req, cfg).ok().map(|auth| auth.user), 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /public-api/src/routes/account.rs: -------------------------------------------------------------------------------- 1 | //! /account 2 | 3 | use crate::prelude::*; 4 | use crate::views; 5 | 6 | use crate::app_state::AppState; 7 | use crate::auth::Auth; 8 | use crate::handlers::account::create::*; 9 | use crate::handlers::account::login::*; 10 | use crate::handlers::account::update::*; 11 | use actix_web::State; 12 | 13 | /// POST /account 14 | pub fn create( 15 | account: Json, 16 | state: State, 17 | ) -> FutureResponse { 18 | #[derive(Serialize)] 19 | #[serde(rename_all = "camelCase")] 20 | struct R { 21 | user_id: i32, 22 | } 23 | 24 | state 25 | .pg 26 | .send(account.0) 27 | .from_err() 28 | .and_then(|res| match res { 29 | Ok(user) => Ok(answer_success!(Created, R { user_id: user.id })), 30 | Err(err) => Ok(err.error_response()), 31 | }) 32 | .responder() 33 | } 34 | 35 | #[derive(Deserialize)] 36 | #[serde(rename_all = "camelCase")] 37 | struct Update { 38 | display_name: String, 39 | gravatar_email: String, 40 | username: String, 41 | } 42 | 43 | #[derive(Serialize)] 44 | #[serde(rename_all = "camelCase")] 45 | struct SettingsResponse { 46 | settings: views::UserSettings, 47 | } 48 | 49 | /// PUT /account/settings/ 50 | fn update( 51 | state: State, 52 | auth: Auth, 53 | update: Json, 54 | ) -> FutureResponse { 55 | state 56 | .pg 57 | .send(AccountUpdate { 58 | requester_id: auth.user.id, 59 | display_name: update.display_name.clone(), 60 | gravatar_email: update.gravatar_email.clone(), 61 | username: update.username.clone(), 62 | }) 63 | .from_err() 64 | .and_then(|res| match res { 65 | Ok(user) => Ok(answer_success!( 66 | Ok, 67 | SettingsResponse { 68 | settings: user.encodable_settings() 69 | } 70 | )), 71 | Err(err) => Ok(err.error_response()), 72 | }) 73 | .responder() 74 | } 75 | 76 | pub fn settings(auth: Auth) -> FutureResponse { 77 | futures::future::ok(answer_success!( 78 | Ok, 79 | SettingsResponse { 80 | settings: auth.user.encodable_settings() 81 | } 82 | )) 83 | .responder() 84 | } 85 | 86 | /// POST /account/session 87 | pub fn login( 88 | login_data: Json, 89 | state: State, 90 | ) -> FutureResponse { 91 | #[derive(Serialize)] 92 | #[serde(rename_all = "camelCase")] 93 | struct R { 94 | token: String, 95 | user: views::EncodableUserPrivate, 96 | } 97 | 98 | state 99 | .pg 100 | .send(login_data.0) 101 | .from_err() 102 | .and_then(|res| match res { 103 | Ok(login_info) => Ok(answer_success!( 104 | Ok, 105 | R { 106 | token: login_info.token, 107 | user: login_info.user.encodable_private(), 108 | } 109 | )), 110 | Err(err) => Ok(err.error_response()), 111 | }) 112 | .responder() 113 | } 114 | 115 | #[derive(Serialize)] 116 | #[serde(rename_all = "camelCase")] 117 | struct SessionInfo { 118 | user: views::EncodableUserPrivate, 119 | } 120 | 121 | /// GET /account/session 122 | pub fn get_session(auth: Auth) -> HttpResponse { 123 | answer_success!( 124 | Ok, 125 | SessionInfo { 126 | user: auth.user.encodable_private(), 127 | } 128 | ) 129 | } 130 | 131 | #[inline] 132 | pub fn scope(scope: Scope) -> Scope { 133 | scope 134 | .resource("/", |r| { 135 | r.post().with(self::create); 136 | }) 137 | .resource("/settings/", |r| { 138 | r.get().with(self::settings); 139 | r.put().with(self::update); 140 | }) 141 | .resource("/session/", |r| { 142 | r.post().with(self::login); 143 | r.get().with(self::get_session) 144 | }) 145 | } 146 | -------------------------------------------------------------------------------- /public-api/src/routes/users.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use crate::app_state::AppState; 4 | use crate::auth::AuthOptional; 5 | 6 | use crate::models::{Card, User}; 7 | use crate::views::{EncodableUserPrivate, EncodableUserPublic}; 8 | 9 | type FutRes = FutureResponse; 10 | 11 | #[derive(Deserialize)] 12 | pub struct UserPath { 13 | username: String, 14 | } 15 | 16 | #[derive(Fail, Debug)] 17 | enum GetUserInfoError { 18 | #[fail(display = "user_not_found")] 19 | NotFound, 20 | } 21 | /// GET /users/{username}/ 22 | pub fn info(auth: AuthOptional, path: Path, state: State) -> FutRes { 23 | use crate::handlers::users::get_user::*; 24 | 25 | #[derive(Serialize)] 26 | struct R { 27 | user: UserView, 28 | } 29 | 30 | #[derive(Serialize)] 31 | #[serde(untagged)] 32 | enum UserView { 33 | Public(EncodableUserPublic), 34 | Private(EncodableUserPrivate), 35 | } 36 | 37 | impl UserView { 38 | #[inline] 39 | fn answer(auth: AuthOptional, user: User) -> Self { 40 | if auth 41 | .user 42 | .map(|auth_user| auth_user.id == user.id) 43 | .unwrap_or(false) 44 | { 45 | UserView::Private(user.encodable_private()) 46 | } else { 47 | UserView::Public(user.encodable_public()) 48 | } 49 | } 50 | } 51 | 52 | state 53 | .pg 54 | .send(GetUser { 55 | username: path.username.clone(), 56 | }) 57 | .from_err() 58 | .and_then(|res| match res { 59 | Some(user) => Ok(answer_success!( 60 | Ok, 61 | R { 62 | user: UserView::answer(auth, user), 63 | } 64 | )), 65 | None => Ok(answer_error!( 66 | NotFound, 67 | GetUserInfoError::NotFound.to_string() 68 | )), 69 | }) 70 | .responder() 71 | } 72 | 73 | #[derive(Deserialize)] 74 | pub struct CardsQuery { 75 | count: Option, 76 | } 77 | 78 | /// GET /users/{username}/cards/useful/ 79 | pub fn useful( 80 | _auth: AuthOptional, 81 | path: Path, 82 | state: State, 83 | query: Query, 84 | ) -> FutRes { 85 | use crate::handlers::users::useful_cards::*; 86 | #[derive(Serialize)] 87 | #[serde(rename_all = "camelCase")] 88 | struct R { 89 | cards: Vec, 90 | } 91 | 92 | state 93 | .pg 94 | .send(GetUsefulCardsForUser { 95 | username: path.username.clone(), 96 | count: query.count.clone(), 97 | }) 98 | .from_err() 99 | .and_then(|res| match res { 100 | Some(cards) => Ok(answer_success!(Ok, R { cards })), 101 | None => Ok(answer_success!(Ok, R { cards: vec![] })), 102 | }) 103 | .responder() 104 | } 105 | 106 | /// GET /users/{username}/cards/authors/ 107 | /// Get cards by user 108 | pub fn authors( 109 | _auth: AuthOptional, 110 | path: Path, 111 | state: State, 112 | query: Query, 113 | ) -> FutRes { 114 | use crate::handlers::users::cards_by_author::*; 115 | #[derive(Serialize)] 116 | #[serde(rename_all = "camelCase")] 117 | struct R { 118 | cards: Vec, 119 | } 120 | 121 | state 122 | .pg 123 | .send(GetCardsByAuthor { 124 | author_username: path.username.clone(), 125 | count: query.count.clone(), 126 | }) 127 | .from_err() 128 | .and_then(|res| match res { 129 | Some(cards) => Ok(answer_success!(Ok, R { cards })), 130 | None => Ok(answer_success!(Ok, R { cards: vec![] })), 131 | }) 132 | .responder() 133 | } 134 | 135 | #[inline] 136 | pub fn scope(scope: Scope) -> Scope { 137 | scope 138 | .resource("/{username}/", |r| { 139 | r.get().with(self::info); 140 | }) 141 | .resource("/{username}/cards/useful/", |r| { 142 | r.get().with(self::useful); 143 | }) 144 | .resource("/{username}/cards/authors/", |r| { 145 | r.get().with(self::authors) 146 | }) 147 | } 148 | -------------------------------------------------------------------------------- /public-api/src/slate/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use serde_json::{Map, Value}; 4 | 5 | pub fn plain_serialize(value: &Value) -> String { 6 | if is_iterable(&value) { 7 | if let Some(Value::Array(nodes)) = value.get("nodes") { 8 | nodes 9 | .iter() 10 | .filter(|node| !is_type_code(&node)) 11 | .map(|node| plain_serialize(&node)) 12 | .fold(String::new(), fold_text_nodes) 13 | .trim() 14 | .to_string() 15 | } else { 16 | String::from("") 17 | } 18 | } else if is_text_node(&value) { 19 | if let Some(Value::Array(leaves)) = value.get("leaves") { 20 | leaves 21 | .iter() 22 | .map(|node| plain_serialize(&node)) 23 | .fold(String::new(), fold_text_nodes) 24 | .trim() 25 | .to_string() 26 | } else { 27 | String::from("") 28 | } 29 | } else if is_root(&value) { 30 | plain_serialize(value.get("document").unwrap()) 31 | } else if has_text_field(&value) { 32 | if let Some(Value::String(text)) = value.get("text") { 33 | text.trim().to_string() 34 | } else { 35 | String::new() 36 | } 37 | } else { 38 | String::new() 39 | } 40 | } 41 | 42 | fn fold_text_nodes(acc: String, text: String) -> String { 43 | if text.trim().len() > 0 { 44 | acc + " " + text.trim() 45 | } else { 46 | acc 47 | } 48 | } 49 | 50 | fn is_text_node(value: &Value) -> bool { 51 | if let Value::Object(map) = value { 52 | check_value(&map, "object", is_text) && check_value(&map, "leaves", |node| node.is_array()) 53 | } else { 54 | false 55 | } 56 | } 57 | 58 | fn has_text_field(value: &Value) -> bool { 59 | if let Value::Object(map) = value { 60 | map.get("text").is_some() 61 | } else { 62 | false 63 | } 64 | } 65 | 66 | fn is_root(value: &Value) -> bool { 67 | match value { 68 | Value::Object(map) => { 69 | check_value(&map, "object", is_value) 70 | && check_value(&map, "document", |node| node.is_object()) 71 | } 72 | _ => false, 73 | } 74 | } 75 | 76 | fn is_iterable(value: &Value) -> bool { 77 | match value { 78 | Value::Object(map) => { 79 | check_value(&map, "object", is_document) 80 | || (check_value(&map, "object", is_block) 81 | && check_value(&map, "nodes", |node| node.is_array())) 82 | } 83 | _ => false, 84 | } 85 | } 86 | 87 | #[inline] 88 | fn is_value(value: &Value) -> bool { 89 | match value { 90 | Value::String(s) => s == "value", 91 | _ => false, 92 | } 93 | } 94 | 95 | #[inline] 96 | fn is_document(value: &Value) -> bool { 97 | match value { 98 | Value::String(s) => s == "document", 99 | _ => false, 100 | } 101 | } 102 | 103 | #[inline] 104 | fn is_block(value: &Value) -> bool { 105 | match value { 106 | Value::String(s) => s == "block", 107 | _ => false, 108 | } 109 | } 110 | 111 | fn is_type_code(value: &Value) -> bool { 112 | match value { 113 | Value::Object(map) => check_value(&map, "type", is_code), 114 | _ => false, 115 | } 116 | } 117 | 118 | fn is_code(value: &Value) -> bool { 119 | match value { 120 | Value::String(s) => s == "code" || s == "code_line" || s == "code_block", 121 | _ => false, 122 | } 123 | } 124 | 125 | fn is_text(value: &Value) -> bool { 126 | if let Value::String(s) = value { 127 | s == "text" 128 | } else { 129 | false 130 | } 131 | } 132 | 133 | fn check_value(map: &Map, name: S, validator: V) -> bool 134 | where 135 | V: Fn(&Value) -> bool, 136 | S: ToString, 137 | { 138 | if let Some(value) = map.get(&name.to_string()) { 139 | validator(value) 140 | } else { 141 | false 142 | } 143 | } 144 | 145 | #[cfg(test)] 146 | mod test { 147 | 148 | #[test] 149 | fn slate_document_serialized_to_plain_string_without_html() { 150 | use super::plain_serialize; 151 | use serde_json::Value; 152 | 153 | let json = include_str!("example_slate_document.json"); 154 | let value: Value = serde_json::from_str(&json).unwrap(); 155 | 156 | let result = plain_serialize(&value); 157 | let expected = r#"bold italic underline blockquote list 32"#; 158 | 159 | assert_eq!(result, expected); 160 | } 161 | 162 | #[test] 163 | fn slate_document_serialized_to_plain_string_big_example() { 164 | use super::plain_serialize; 165 | use serde_json::Value; 166 | 167 | let json = include_str!("big_example.json"); 168 | let value: Value = serde_json::from_str(&json).unwrap(); 169 | 170 | let result = plain_serialize(&value); 171 | let expected = r#"one two three one-1 two-1 bold text-test-ltialic . ret4eg4534regt34rw 43 three-1 цитатацитатацитатацитатацитатацитатацитатацитатацитата цитатацитатацитатацитатацитатацитатацитатацитата цитатацитатацитата"#; 172 | 173 | assert_eq!(result, expected); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /public-api/src/models/user.rs: -------------------------------------------------------------------------------- 1 | use crate::views::{EncodableUserPrivate, EncodableUserPublic, UserSettings}; 2 | use diesel::prelude::*; 3 | use howtocards_db::schema::users; 4 | 5 | #[derive(Queryable, Associations, Identifiable, Debug, Clone)] 6 | pub struct User { 7 | pub id: i32, 8 | pub email: String, 9 | pub password: String, 10 | pub display_name: Option, 11 | pub gravatar_email: Option, 12 | pub username: String, 13 | } 14 | 15 | impl User { 16 | fn avatar_url(&self) -> String { 17 | let email = self 18 | .gravatar_email 19 | .clone() 20 | .and_then(|email| { 21 | if email.trim().len() == 0 { 22 | None 23 | } else { 24 | Some(email) 25 | } 26 | }) 27 | .unwrap_or_else(|| self.email.clone()); 28 | 29 | crate::gravatar::create_avatar_url(email) 30 | } 31 | 32 | /// Converts this User model into an public for serialization 33 | pub fn encodable_public(self) -> EncodableUserPublic { 34 | let User { 35 | id, 36 | display_name, 37 | username, 38 | .. 39 | } = self.clone(); 40 | 41 | EncodableUserPublic { 42 | id, 43 | display_name, 44 | avatar: self.avatar_url(), 45 | username, 46 | } 47 | } 48 | 49 | /// Converts this User model into an private for serialization 50 | pub fn encodable_private(self) -> EncodableUserPrivate { 51 | let User { 52 | display_name, 53 | email, 54 | id, 55 | username, 56 | .. 57 | } = self.clone(); 58 | 59 | EncodableUserPrivate { 60 | display_name, 61 | email, 62 | id, 63 | avatar: self.avatar_url(), 64 | username, 65 | } 66 | } 67 | 68 | pub fn encodable_settings(self) -> UserSettings { 69 | let User { 70 | display_name, 71 | email, 72 | gravatar_email, 73 | username, 74 | .. 75 | } = self; 76 | 77 | UserSettings { 78 | display_name, 79 | gravatar_email, 80 | current_email: Some(email), 81 | username, 82 | } 83 | } 84 | 85 | pub fn find_by_username(conn: &PgConnection, username: String) -> Option { 86 | users::table 87 | .filter(users::username.eq(username)) 88 | .get_result(conn) 89 | .ok() 90 | } 91 | 92 | pub fn find_by_id(conn: &PgConnection, user_id: i32) -> Option { 93 | use howtocards_db::schema::users::dsl::*; 94 | 95 | users.find(user_id).get_result::(conn).ok() 96 | } 97 | 98 | pub fn create(conn: &PgConnection, new_user: UserNew) -> Option { 99 | diesel::insert_into(users::table) 100 | .values(&new_user) 101 | .get_result(conn) 102 | .ok() 103 | } 104 | 105 | pub fn find_by_credentials(conn: &PgConnection, credentials: Credentials) -> Option { 106 | users::table 107 | .filter(users::email.eq(credentials.email)) 108 | .filter(users::password.eq(credentials.password)) 109 | .get_result(conn) 110 | .ok() 111 | } 112 | 113 | pub fn find_by_token(conn: &PgConnection, token: String) -> Option { 114 | use howtocards_db::schema::tokens; 115 | 116 | tokens::table 117 | .inner_join(users::table) 118 | .filter(tokens::token.eq(token)) 119 | .select(users::all_columns) 120 | .get_result(conn) 121 | .ok() 122 | } 123 | 124 | pub fn update( 125 | conn: &PgConnection, 126 | user_id: i32, 127 | display_name: String, 128 | gravatar_email: String, 129 | username: String, 130 | ) -> Result { 131 | let target = users::table.filter(users::id.eq(user_id)); 132 | 133 | diesel::update(target) 134 | .set(( 135 | users::display_name.eq(display_name.to_option()), 136 | users::gravatar_email.eq(gravatar_email.to_option()), 137 | users::username.eq(username), 138 | )) 139 | .returning(users::all_columns) 140 | .get_result(conn) 141 | .map_err(UpdateError::from) 142 | } 143 | } 144 | 145 | #[derive(Deserialize, Insertable, Queryable)] 146 | #[table_name = "users"] 147 | #[serde(rename_all = "camelCase")] 148 | pub struct UserNew { 149 | pub email: String, 150 | pub password: String, 151 | pub username: String, 152 | } 153 | 154 | #[derive(Deserialize, Insertable, Queryable)] 155 | #[table_name = "users"] 156 | #[serde(rename_all = "camelCase")] 157 | pub struct Credentials { 158 | pub email: String, 159 | pub password: String, 160 | } 161 | 162 | trait ToOption: 'static + Sized { 163 | fn to_option(self) -> Option; 164 | } 165 | 166 | impl ToOption for String { 167 | fn to_option(self) -> Option { 168 | if self.is_empty() { 169 | None 170 | } else { 171 | Some(self) 172 | } 173 | } 174 | } 175 | 176 | use diesel::result::DatabaseErrorKind; 177 | use diesel::result::Error as DieselError; 178 | 179 | #[derive(Debug)] 180 | pub enum UpdateError { 181 | UsernameTaken, 182 | Unexpected, 183 | } 184 | 185 | impl From for UpdateError { 186 | fn from(error: DieselError) -> UpdateError { 187 | match error { 188 | DieselError::DatabaseError(kind, _) => match kind { 189 | DatabaseErrorKind::UniqueViolation => UpdateError::UsernameTaken, 190 | _ => UpdateError::Unexpected, 191 | }, 192 | _ => UpdateError::Unexpected, 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /public-api/src/routes/cards.rs: -------------------------------------------------------------------------------- 1 | //! /cards 2 | use crate::prelude::*; 3 | use serde_json::Value; 4 | 5 | use crate::app_state::AppState; 6 | use crate::auth::{Auth, AuthOptional}; 7 | use crate::models::*; 8 | use crate::preview; 9 | use crate::views::CardMeta as CardMetaView; 10 | use actix_web::{Query, State}; 11 | 12 | type FutRes = FutureResponse; 13 | 14 | #[derive(Deserialize)] 15 | #[serde(rename_all = "camelCase")] 16 | pub struct CardCreateBody { 17 | content: Value, 18 | title: String, 19 | } 20 | 21 | #[derive(Deserialize)] 22 | pub struct CardListQuery { 23 | count: Option, 24 | } 25 | 26 | /// POST /cards 27 | pub fn create(card_form: Json, auth: Auth, state: State) -> FutRes { 28 | let queue_url = state.preview_queue_url.clone(); 29 | state 30 | .pg 31 | .send(CardNew { 32 | author_id: auth.user.id, 33 | content: card_form.0.content, 34 | title: card_form.0.title, 35 | }) 36 | .from_err() 37 | .and_then(move |res| match res { 38 | Ok(created) => { 39 | preview::create_for_card(created.id as u32, queue_url.clone()) 40 | .unwrap_or_else(|_| {}); 41 | Ok(answer_success!(Ok, created)) 42 | } 43 | Err(err) => Ok(err.error_response()), 44 | }) 45 | .responder() 46 | } 47 | 48 | /// GET /cards 49 | pub fn list(auth: AuthOptional, state: State, query: Query) -> FutRes { 50 | use crate::handlers::cards::list::*; 51 | 52 | #[derive(Serialize)] 53 | pub struct R(Vec); 54 | 55 | state 56 | .pg 57 | .send(CardsListFetch { 58 | requester_id: auth.user.map(|user| user.id), 59 | count: query.count.clone(), 60 | }) 61 | .from_err() 62 | .and_then(|res| match res { 63 | Some(list) => Ok(answer_success!(Ok, R(list))), 64 | None => Ok(answer_success!(Ok, R(vec![]))), 65 | }) 66 | .responder() 67 | } 68 | 69 | #[derive(Deserialize)] 70 | pub struct CardPath { 71 | card_id: u32, 72 | } 73 | 74 | /// GET /cards/{card_id} 75 | pub fn get(auth: AuthOptional, path: Path, state: State) -> FutRes { 76 | use crate::handlers::cards::get::*; 77 | 78 | #[derive(Serialize)] 79 | pub struct R { 80 | card: Card, 81 | } 82 | 83 | state 84 | .pg 85 | .send(CardFetch { 86 | card_id: path.card_id, 87 | requester_id: auth.user.map(|user| user.id), 88 | }) 89 | .from_err() 90 | .and_then(|res| match res { 91 | Some(card) => Ok(answer_success!(Ok, R { card })), 92 | None => Ok(answer_error!(NotFound, "id_not_found".to_string())), 93 | }) 94 | .responder() 95 | } 96 | 97 | #[derive(Deserialize)] 98 | #[serde(rename_all = "camelCase")] 99 | pub struct CardEditBody { 100 | content: Option, 101 | title: Option, 102 | } 103 | 104 | /// PUT /cards/{card_id} 105 | pub fn edit( 106 | auth: Auth, 107 | path: Path, 108 | edit_form: Json, 109 | state: State, 110 | ) -> FutRes { 111 | use crate::handlers::cards::edit::*; 112 | let queue_url = state.preview_queue_url.clone(); 113 | 114 | #[derive(Serialize)] 115 | #[serde(rename_all = "camelCase")] 116 | pub struct R { 117 | card: Card, 118 | } 119 | 120 | state 121 | .pg 122 | .send(CardEdit { 123 | card_id: path.card_id, 124 | requester_id: auth.user.id, 125 | title: edit_form.0.title, 126 | content: edit_form.0.content, 127 | }) 128 | .from_err() 129 | .and_then(move |res| match res { 130 | Ok(card) => { 131 | preview::create_for_card(card.id as u32, queue_url.clone()).unwrap_or_else(|_| {}); 132 | Ok(answer_success!(Ok, R { card })) 133 | } 134 | Err(err) => Ok(err.error_response()), 135 | }) 136 | .responder() 137 | } 138 | 139 | /// DELETE /cards/{card_id} 140 | pub fn delete(auth: Auth, path: Path, state: State) -> FutRes { 141 | use crate::handlers::cards::delete::*; 142 | 143 | #[derive(Serialize)] 144 | pub struct R { 145 | card: Card, 146 | } 147 | 148 | state 149 | .pg 150 | .send(CardDelete { 151 | requester_id: auth.user.id, 152 | card_id: path.card_id, 153 | }) 154 | .from_err() 155 | .and_then(|res| match res { 156 | Ok(card) => Ok(answer_success!(Accepted, R { card })), 157 | Err(err) => Ok(err.error_response()), 158 | }) 159 | .responder() 160 | } 161 | 162 | #[derive(Deserialize)] 163 | #[serde(rename_all = "camelCase")] 164 | pub struct SetCardUseful { 165 | is_useful: bool, 166 | } 167 | 168 | /// POST /cards/{card_id}/useful/ 169 | pub fn toggle_useful( 170 | auth: Auth, 171 | path: Path, 172 | body: Json, 173 | state: State, 174 | ) -> FutRes { 175 | use crate::handlers::cards::toggle_useful_mark::*; 176 | 177 | #[derive(Serialize)] 178 | #[serde(rename_all = "camelCase")] 179 | pub struct R { 180 | card: Card, 181 | } 182 | 183 | state 184 | .pg 185 | .send(ToggleUsefulMark { 186 | requester_id: auth.user.id, 187 | card_id: path.card_id as i32, 188 | set_is_useful: body.is_useful, 189 | }) 190 | .from_err() 191 | .and_then(|res| match res { 192 | Ok(card) => Ok(answer_success!(Ok, R { card })), 193 | Err(err) => Ok(err.error_response()), 194 | }) 195 | .responder() 196 | } 197 | /// GET /cards/{card_id}/meta/ 198 | pub fn meta(auth: AuthOptional, path: Path, state: State) -> FutRes { 199 | use crate::handlers::cards::get::*; 200 | 201 | #[derive(Serialize)] 202 | pub struct R { 203 | meta: CardMetaView, 204 | } 205 | 206 | state 207 | .pg 208 | .send(CardFetch { 209 | card_id: path.card_id, 210 | requester_id: auth.user.map(|user| user.id), 211 | }) 212 | .from_err() 213 | .and_then(|res| match res { 214 | Some(card) => Ok(answer_success!( 215 | Ok, 216 | R { 217 | meta: card.encodable_meta() 218 | } 219 | )), 220 | None => Ok(answer_error!(NotFound, "id_not_found".to_string())), 221 | }) 222 | .responder() 223 | } 224 | 225 | #[inline] 226 | pub fn scope(scope: Scope) -> Scope { 227 | scope 228 | .resource("/{card_id}/", |r| { 229 | r.get().with(self::get); 230 | r.put().with(self::edit); 231 | r.delete().with(self::delete); 232 | }) 233 | .resource("/{card_id}/useful/", |r| { 234 | r.post().with(self::toggle_useful); 235 | }) 236 | .resource("/{card_id}/meta/", |r| { 237 | r.get().with(self::meta); 238 | }) 239 | .resource("/", |r| { 240 | r.post().with(self::create); 241 | r.get().with(self::list); 242 | }) 243 | } 244 | -------------------------------------------------------------------------------- /public-api/src/slate/example_slate_document.json: -------------------------------------------------------------------------------- 1 | { 2 | "document": { 3 | "data": {}, 4 | "nodes": [ 5 | { 6 | "data": { "language": "jsx" }, 7 | "nodes": [ 8 | { 9 | "data": {}, 10 | "nodes": [ 11 | { 12 | "leaves": [ 13 | { 14 | "marks": [], 15 | "object": "leaf", 16 | "text": " if (type === configCodePlugin.block) {" 17 | } 18 | ], 19 | "object": "text" 20 | } 21 | ], 22 | "object": "block", 23 | "type": "code_line" 24 | }, 25 | { 26 | "data": {}, 27 | "nodes": [ 28 | { 29 | "leaves": [ 30 | { 31 | "marks": [], 32 | "object": "leaf", 33 | "text": " const isCodeLine = hasBlock(configCodePlugin.line, props)" 34 | } 35 | ], 36 | "object": "text" 37 | } 38 | ], 39 | "object": "block", 40 | "type": "code_line" 41 | }, 42 | { 43 | "data": {}, 44 | "nodes": [ 45 | { 46 | "leaves": [ 47 | { 48 | "marks": [], 49 | "object": "leaf", 50 | "text": " const isType = value.blocks.some((block) =>" 51 | } 52 | ], 53 | "object": "text" 54 | } 55 | ], 56 | "object": "block", 57 | "type": "code_line" 58 | }, 59 | { 60 | "data": {}, 61 | "nodes": [ 62 | { 63 | "leaves": [ 64 | { 65 | "marks": [], 66 | "object": "leaf", 67 | "text": " Boolean(document.getClosest(block.key, (parent) => parent.type === type))," 68 | } 69 | ], 70 | "object": "text" 71 | } 72 | ], 73 | "object": "block", 74 | "type": "code_line" 75 | }, 76 | { 77 | "data": {}, 78 | "nodes": [ 79 | { 80 | "leaves": [{ "marks": [], "object": "leaf", "text": " )" }], 81 | "object": "text" 82 | } 83 | ], 84 | "object": "block", 85 | "type": "code_line" 86 | }, 87 | { 88 | "data": {}, 89 | "nodes": [ 90 | { 91 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 92 | "object": "text" 93 | } 94 | ], 95 | "object": "block", 96 | "type": "code_line" 97 | }, 98 | { 99 | "data": {}, 100 | "nodes": [ 101 | { 102 | "leaves": [ 103 | { 104 | "marks": [], 105 | "object": "leaf", 106 | "text": " unWrapBlocks(editor, [\"bulleted-list\", \"numbered-list\", \"block-quote\"])" 107 | } 108 | ], 109 | "object": "text" 110 | } 111 | ], 112 | "object": "block", 113 | "type": "code_line" 114 | }, 115 | { 116 | "data": {}, 117 | "nodes": [ 118 | { 119 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 120 | "object": "text" 121 | } 122 | ], 123 | "object": "block", 124 | "type": "line" 125 | } 126 | ], 127 | "object": "block", 128 | "type": "code" 129 | }, 130 | { 131 | "data": {}, 132 | "nodes": [ 133 | { 134 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 135 | "object": "text" 136 | } 137 | ], 138 | "object": "block", 139 | "type": "paragraph" 140 | }, 141 | { 142 | "data": {}, 143 | "nodes": [ 144 | { 145 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 146 | "object": "text" 147 | } 148 | ], 149 | "object": "block", 150 | "type": "paragraph" 151 | }, 152 | { 153 | "data": {}, 154 | "nodes": [ 155 | { 156 | "leaves": [ 157 | { 158 | "marks": [{ "data": {}, "object": "mark", "type": "bold" }], 159 | "object": "leaf", 160 | "text": "bold" 161 | } 162 | ], 163 | "object": "text" 164 | } 165 | ], 166 | "object": "block", 167 | "type": "paragraph" 168 | }, 169 | { 170 | "data": {}, 171 | "nodes": [ 172 | { 173 | "leaves": [ 174 | { 175 | "marks": [{ "data": {}, "object": "mark", "type": "italic" }], 176 | "object": "leaf", 177 | "text": "italic" 178 | } 179 | ], 180 | "object": "text" 181 | } 182 | ], 183 | "object": "block", 184 | "type": "paragraph" 185 | }, 186 | { 187 | "data": {}, 188 | "nodes": [ 189 | { 190 | "leaves": [ 191 | { 192 | "marks": [ 193 | { "data": {}, "object": "mark", "type": "underlined" } 194 | ], 195 | "object": "leaf", 196 | "text": "underline" 197 | } 198 | ], 199 | "object": "text" 200 | } 201 | ], 202 | "object": "block", 203 | "type": "paragraph" 204 | }, 205 | { 206 | "data": {}, 207 | "nodes": [ 208 | { 209 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 210 | "object": "text" 211 | } 212 | ], 213 | "object": "block", 214 | "type": "paragraph" 215 | }, 216 | { 217 | "data": {}, 218 | "nodes": [ 219 | { 220 | "data": {}, 221 | "nodes": [ 222 | { 223 | "leaves": [ 224 | { "marks": [], "object": "leaf", "text": "blockquote" } 225 | ], 226 | "object": "text" 227 | } 228 | ], 229 | "object": "block", 230 | "type": "paragraph" 231 | } 232 | ], 233 | "object": "block", 234 | "type": "block-quote" 235 | }, 236 | { 237 | "data": {}, 238 | "nodes": [ 239 | { 240 | "data": {}, 241 | "nodes": [ 242 | { 243 | "leaves": [{ "marks": [], "object": "leaf", "text": "list" }], 244 | "object": "text" 245 | } 246 | ], 247 | "object": "block", 248 | "type": "list-item" 249 | } 250 | ], 251 | "object": "block", 252 | "type": "numbered-list" 253 | }, 254 | { 255 | "data": {}, 256 | "nodes": [ 257 | { 258 | "data": {}, 259 | "nodes": [ 260 | { 261 | "leaves": [{ "marks": [], "object": "leaf", "text": "32" }], 262 | "object": "text" 263 | } 264 | ], 265 | "object": "block", 266 | "type": "list-item" 267 | } 268 | ], 269 | "object": "block", 270 | "type": "bulleted-list" 271 | } 272 | ], 273 | "object": "document" 274 | }, 275 | "object": "value" 276 | } 277 | -------------------------------------------------------------------------------- /public-api/src/models/card.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | 3 | use crate::models::User; 4 | use crate::slate::plain_serialize; 5 | use crate::time; 6 | use crate::views::CardMeta as CardMetaView; 7 | use diesel::dsl::sql; 8 | use diesel::prelude::*; 9 | use howtocards_db::schema::cards; 10 | use serde_json::Value; 11 | 12 | #[derive(Debug, Deserialize)] 13 | #[serde(rename_all = "camelCase")] 14 | pub struct CardNew { 15 | pub author_id: i32, 16 | pub title: String, 17 | pub content: Value, 18 | } 19 | 20 | impl Into for CardNew { 21 | fn into(self) -> CardNewForSearch { 22 | let content_for_search = plain_serialize(&self.content); 23 | CardNewForSearch { 24 | author_id: self.author_id, 25 | title: self.title, 26 | content: self.content, 27 | content_for_search, 28 | } 29 | } 30 | } 31 | 32 | #[derive(Debug, Deserialize, Insertable, Associations)] 33 | #[belongs_to(User, foreign_key = "author_id")] 34 | #[table_name = "cards"] 35 | #[serde(rename_all = "camelCase")] 36 | pub struct CardNewForSearch { 37 | pub author_id: i32, 38 | pub title: String, 39 | pub content: Value, 40 | pub content_for_search: String, 41 | } 42 | 43 | #[derive(Serialize, Deserialize, Queryable, Default, Debug)] 44 | #[serde(rename_all = "camelCase")] 45 | pub struct Permissions { 46 | pub is_useful: bool, 47 | pub can_edit: bool, 48 | } 49 | 50 | #[derive(Queryable, Serialize, Deserialize, Associations, Identifiable, Default, Debug)] 51 | #[belongs_to(User, foreign_key = "author_id")] 52 | #[serde(rename_all = "camelCase")] 53 | pub struct Card { 54 | pub id: i32, 55 | pub author_id: i32, 56 | pub title: String, 57 | pub content: Value, 58 | pub created_at: Option, 59 | pub updated_at: Option, 60 | /// Count of users, that added card to its library 61 | pub useful_for: i64, 62 | pub permissions: Permissions, 63 | #[serde(skip)] 64 | pub content_for_search: String, 65 | pub preview_url: Option, 66 | pub tags: Vec, 67 | } 68 | 69 | pub type AllColumns = ( 70 | howtocards_db::schema::cards::id, 71 | howtocards_db::schema::cards::author_id, 72 | howtocards_db::schema::cards::title, 73 | howtocards_db::schema::cards::content, 74 | howtocards_db::schema::cards::created_at, 75 | howtocards_db::schema::cards::updated_at, 76 | howtocards_db::schema::cards::useful_for, 77 | // permissions 78 | ( 79 | diesel::expression::SqlLiteral, 80 | diesel::expression::SqlLiteral, 81 | ), 82 | howtocards_db::schema::cards::content_for_search, 83 | howtocards_db::schema::cards::preview_url, 84 | howtocards_db::schema::cards::tags, 85 | ); 86 | 87 | impl Card { 88 | #[inline] 89 | pub fn all_columns(requester_id: i32) -> AllColumns { 90 | use howtocards_db::schema::cards::dsl::*; 91 | 92 | ( 93 | id, 94 | author_id, 95 | title, 96 | content, 97 | created_at, 98 | updated_at, 99 | useful_for, 100 | // permissions 101 | ( 102 | // Card is useful if useful_marks more than one 103 | sql(format!( 104 | "CASE WHEN (select count(*) from useful_marks WHERE user_id={} AND card_id=cards.id)=1 THEN true ELSE false END AS is_useful", 105 | requester_id 106 | ).as_str()), 107 | 108 | // User can edit card if he is author 109 | sql(format!( 110 | "CASE WHEN author_id={} THEN true ELSE false END AS can_edit", 111 | requester_id 112 | ) 113 | .as_str()), 114 | ), 115 | content_for_search, 116 | preview_url, 117 | tags, 118 | ) 119 | } 120 | 121 | pub fn select_for(requester_id: i32) -> diesel::dsl::Select { 122 | use howtocards_db::schema::cards::dsl::*; 123 | 124 | cards.select(Self::all_columns(requester_id)) 125 | } 126 | 127 | pub fn find_by_id(conn: &PgConnection, card_id: i32, requester_id: i32) -> Option { 128 | Self::select_for(requester_id) 129 | .find(card_id) 130 | .get_result(conn) 131 | .ok() 132 | } 133 | 134 | pub fn get_latest_cards(conn: &PgConnection, requester_id: i32, count: u32) -> Vec { 135 | Self::select_for(requester_id) 136 | .order(cards::created_at.desc()) 137 | .limit(count.into()) 138 | .get_results(conn) 139 | .unwrap_or_default() 140 | } 141 | 142 | pub fn get_useful_for_user(conn: &PgConnection, user_id: i32, count: u32) -> Vec { 143 | use howtocards_db::schema::useful_marks; 144 | 145 | Self::select_for(user_id) 146 | .inner_join(useful_marks::table) 147 | .order(useful_marks::created_at.desc()) 148 | .filter(useful_marks::user_id.eq(user_id)) 149 | .limit(count.into()) 150 | .load(conn) 151 | .unwrap_or_default() 152 | } 153 | 154 | pub fn find_all_by_author(conn: &PgConnection, author_id: i32, count: u32) -> Vec { 155 | Self::select_for(author_id) 156 | .order(cards::updated_at.desc()) 157 | .filter(cards::author_id.eq(author_id)) 158 | .limit(count.into()) 159 | .get_results(conn) 160 | .unwrap_or_default() 161 | } 162 | 163 | pub fn create(conn: &PgConnection, new_card: CardNew, creator_id: i32) -> Option { 164 | let card: CardNewForSearch = new_card.into(); 165 | diesel::insert_into(cards::table) 166 | .values(&card) 167 | .returning(Self::all_columns(creator_id)) 168 | .get_result(conn) 169 | .ok() 170 | } 171 | 172 | pub fn delete(conn: &PgConnection, card_id: i32, requester_id: i32) -> Option { 173 | let target = cards::table 174 | .filter(cards::id.eq(card_id)) 175 | .filter(cards::author_id.eq(requester_id)); 176 | 177 | diesel::delete(target) 178 | .returning(Self::all_columns(requester_id)) 179 | .get_result(conn) 180 | .ok() 181 | } 182 | 183 | pub fn update( 184 | conn: &PgConnection, 185 | card_id: i32, 186 | requester_id: i32, 187 | title: String, 188 | content: Value, 189 | ) -> Option { 190 | let target = cards::table.filter(cards::id.eq(card_id)); 191 | let content_for_search = plain_serialize(&content); 192 | 193 | diesel::update(target) 194 | .set(( 195 | cards::updated_at.eq(Some(time::now())), 196 | cards::title.eq(title), 197 | cards::content.eq(content), 198 | cards::content_for_search.eq(content_for_search), 199 | )) 200 | .returning(Self::all_columns(requester_id)) 201 | .get_result(conn) 202 | .ok() 203 | } 204 | 205 | pub fn update_useful_for( 206 | conn: &PgConnection, 207 | card_id: i32, 208 | useful_for: i64, 209 | requester_id: i32, 210 | ) -> Option { 211 | let target = cards::table.filter(cards::id.eq(card_id)); 212 | 213 | diesel::update(target) 214 | .set(( 215 | cards::updated_at.eq(Some(time::now())), 216 | cards::useful_for.eq(useful_for), 217 | )) 218 | .returning(Self::all_columns(requester_id)) 219 | .get_result(conn) 220 | .ok() 221 | } 222 | 223 | pub fn encodable_meta(self) -> CardMetaView { 224 | CardMetaView { 225 | id: self.id, 226 | title: self.title, 227 | description: self.content_for_search, 228 | created_at: self.created_at, 229 | updated_at: self.updated_at, 230 | preview_url: self.preview_url, 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /swagger.yml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: "1.0.0" 4 | title: "Howtocards API" 5 | termsOfService: "https://howtocards.io/terms/" 6 | contact: 7 | email: "mail@sergeysova.com" 8 | tags: 9 | - name: "users" 10 | description: "Users on service" 11 | - name: "account" 12 | description: "Current user session" 13 | - name: "cards" 14 | description: "Solutions" 15 | - name: "search" 16 | description: "How to search solution" 17 | 18 | paths: 19 | /users/{userId}: 20 | parameters: 21 | - $ref: "#/components/parameters/userId" 22 | get: 23 | operationId: "userInfo" 24 | tags: ["users"] 25 | description: "Get information about user by ID" 26 | summary: "Info about user" 27 | responses: 28 | "200": 29 | description: "User response" 30 | content: 31 | "application/json": 32 | schema: 33 | oneOf: 34 | - $ref: "#/components/schemas/UserExternal" 35 | - $ref: "#/components/schemas/UserCurrent" 36 | examples: 37 | current: 38 | summary: "When user getting info about himself" 39 | value: 40 | displayName: "Sergey Sova" 41 | email: "sergeysova@howtocards.io" 42 | id: 1 43 | external: 44 | summary: "When getting info about another user" 45 | value: 46 | displayName: "Another User" 47 | id: 2 48 | current_optional: 49 | summary: "Display name is optional (current user)" 50 | value: 51 | email: "sergeysova@howtocards.io" 52 | id: 1 53 | external_optional: 54 | summary: "Display name is optional (external user)" 55 | value: 56 | id: 2 57 | 58 | /users/{userId}/cards/useful: 59 | parameters: 60 | - $ref: "#/components/parameters/userId" 61 | get: 62 | operationId: "usefulCards" 63 | tags: ["users", "cards"] 64 | description: "Get useful cards for specific user" 65 | summary: "Useful cards" 66 | responses: 67 | "200": 68 | description: "List of cards" 69 | content: 70 | "application/json": 71 | schema: 72 | type: "object" 73 | required: 74 | - "cards" 75 | properties: 76 | cards: 77 | type: "array" 78 | items: { $ref: "#/components/schemas/Card" } 79 | 80 | /users/{userId}/cards/authors: 81 | parameters: 82 | - $ref: "#/components/parameters/userId" 83 | get: 84 | operationId: "createdCards" 85 | tags: ["users", "cards"] 86 | description: "Get cards created by user" 87 | summary: "Created cards" 88 | responses: 89 | "200": 90 | description: "List of cards" 91 | content: 92 | "application/json": 93 | schema: 94 | type: "object" 95 | required: 96 | - "cards" 97 | properties: 98 | cards: 99 | type: "array" 100 | items: { $ref: "#/components/schemas/Card" } 101 | 102 | /account: 103 | post: 104 | operationId: "createAccount" 105 | tags: ["account"] 106 | description: "Create account" 107 | summary: "Registration" 108 | requestBody: { $ref: "#/components/requestBodies/AccountCreate" } 109 | responses: 110 | "201": 111 | description: "Information about created account" 112 | content: 113 | "application/json": 114 | schema: 115 | type: "object" 116 | required: 117 | - userId 118 | properties: 119 | userId: 120 | type: "integer" 121 | format: "int32" 122 | example: 123 | userId: 215 124 | 125 | /account/session: 126 | post: 127 | operationId: "createSession" 128 | tags: ["account"] 129 | description: "Create session" 130 | summary: "Login" 131 | requestBody: { $ref: "#/components/requestBodies/SessionCreate" } 132 | responses: 133 | "200": 134 | description: "Token and user info" 135 | content: 136 | "application/json": 137 | schema: 138 | type: "object" 139 | required: 140 | - token 141 | - user 142 | properties: 143 | token: 144 | type: "string" 145 | user: { $ref: "#/components/schemas/UserCurrent" } 146 | example: 147 | token: "b82abfd6-6d8d-4896-80a0-d7b504924477-8c79405c-dff4-41c1-8af3-00b01c165409" 148 | user: 149 | displayName: "Foo Bar" 150 | email: "foo@bar.com" 151 | id: 23 152 | get: 153 | operationId: "getSession" 154 | tags: ["account"] 155 | description: "Get info about current session" 156 | summary: "Current session info" 157 | responses: 158 | "200": 159 | description: "Info about current user" 160 | content: 161 | "application/json": 162 | schema: 163 | type: "object" 164 | properties: 165 | user: { $ref: "#/components/schemas/UserCurrent" } 166 | example: 167 | user: 168 | displayName: "Foo Bar" 169 | email: "foo@bar.com" 170 | id: 23 171 | 172 | /cards/{cardId}: 173 | parameters: 174 | - $ref: "#/components/parameters/cardId" 175 | get: 176 | operationId: "getCard" 177 | tags: ["cards"] 178 | description: "Get card content by ID" 179 | summary: "Get card" 180 | responses: 181 | "200": 182 | description: "Card content" 183 | content: 184 | "application/json": 185 | schema: 186 | required: [card] 187 | properties: 188 | card: { $ref: "#/components/schemas/Card" } 189 | "404": 190 | description: "Card not found" 191 | 192 | put: 193 | operationId: "updateCard" 194 | tags: ["cards"] 195 | description: "Update card title or/and content" 196 | summary: "Update card" 197 | requestBody: { $ref: "#/components/requestBodies/CardEdit" } 198 | responses: 199 | "200": 200 | description: "Updated card" 201 | content: 202 | "application/json": 203 | schema: 204 | required: [card] 205 | properties: 206 | card: { $ref: "#/components/schemas/Card" } 207 | delete: 208 | operationId: "deleteCard" 209 | tags: ["cards"] 210 | description: "Delete card (What about archiving?)" 211 | summary: "Delete card" 212 | deprecated: false 213 | responses: 214 | "202": 215 | description: "Deleted card content" 216 | content: 217 | "application/json": 218 | schema: 219 | required: [card] 220 | properties: 221 | card: { $ref: "#/components/schemas/Card" } 222 | "403": 223 | description: "Can`t be deleted" 224 | content: 225 | "application/json": 226 | schema: 227 | required: [error] 228 | properties: 229 | error: 230 | type: string 231 | 232 | components: 233 | schemas: 234 | UserCurrent: 235 | type: "object" 236 | required: 237 | - "email" 238 | - "id" 239 | properties: 240 | displayName: 241 | type: "string" 242 | email: 243 | type: "string" 244 | id: 245 | type: "integer" 246 | format: "int32" 247 | 248 | UserExternal: 249 | type: "object" 250 | required: 251 | - "id" 252 | properties: 253 | displayName: 254 | type: "string" 255 | id: 256 | type: "integer" 257 | format: "int32" 258 | 259 | Card: 260 | type: "object" 261 | required: 262 | - id 263 | - authorId 264 | - title 265 | - content 266 | - usefulFor 267 | - meta 268 | properties: 269 | id: 270 | type: "integer" 271 | format: "int32" 272 | authorId: 273 | type: "integer" 274 | format: "int32" 275 | title: 276 | type: "string" 277 | content: 278 | type: "object" 279 | createdAt: 280 | type: "string" 281 | format: "date-time" 282 | updatedAt: 283 | type: "string" 284 | format: "date-time" 285 | usefulFor: 286 | type: "integer" 287 | format: "int64" 288 | meta: { $ref: "#/components/schemas/CardMeta" } 289 | example: 290 | id: 401 291 | authorId: 1 292 | title: "How to install PostgreSQL on macOS" 293 | content: 294 | document: 295 | nodes: 296 | - leaves: 297 | - object: "leaf" 298 | text: "Start work with that command" 299 | createdAt: "2019-07-03T09:30:11" 300 | updatedAt: "2019-07-10T14:36:04" 301 | usefulFor: 502 302 | meta: 303 | isUseful: true 304 | canEdit: false 305 | 306 | CardMeta: 307 | type: "object" 308 | required: 309 | - isUseful 310 | - canEdit 311 | properties: 312 | isUseful: 313 | type: "boolean" 314 | canEdit: 315 | type: "boolean" 316 | 317 | parameters: 318 | userId: 319 | name: "userId" 320 | in: "path" 321 | required: true 322 | schema: 323 | type: "integer" 324 | format: "uint32" 325 | 326 | cardId: 327 | name: "cardId" 328 | in: "path" 329 | required: true 330 | schema: 331 | type: "integer" 332 | format: "uint32" 333 | 334 | requestBodies: 335 | AccountCreate: 336 | content: 337 | "application/json": 338 | schema: 339 | type: "object" 340 | required: 341 | - email 342 | - password 343 | properties: 344 | email: 345 | type: "string" 346 | password: 347 | type: "string" 348 | example: 349 | email: "foo@bar.com" 350 | password: "JusTs1mpl3P@ssw0rD" 351 | 352 | SessionCreate: 353 | content: 354 | "application/json": 355 | schema: 356 | type: "object" 357 | required: 358 | - email 359 | - password 360 | properties: 361 | email: 362 | type: "string" 363 | password: 364 | type: "string" 365 | example: 366 | email: "foo@bar.com" 367 | password: "JusTs1mpl3P@ssw0rD" 368 | 369 | CardEdit: 370 | content: 371 | "application/json": 372 | schema: 373 | type: "object" 374 | properties: 375 | content: { type: "object" } 376 | title: { type: "string" } 377 | -------------------------------------------------------------------------------- /public-api/src/slate/big_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "document": { 3 | "data": {}, 4 | "nodes": [ 5 | { 6 | "data": { "language": "json" }, 7 | "nodes": [ 8 | { 9 | "data": {}, 10 | "nodes": [ 11 | { 12 | "leaves": [ 13 | { "marks": [], "object": "leaf", "text": " \"scripts\": {" } 14 | ], 15 | "object": "text" 16 | } 17 | ], 18 | "object": "block", 19 | "type": "code_line" 20 | }, 21 | { 22 | "data": {}, 23 | "nodes": [ 24 | { 25 | "leaves": [ 26 | { 27 | "marks": [], 28 | "object": "leaf", 29 | "text": " \"test\": \"npm run test:eslint && npm run test:code\"," 30 | } 31 | ], 32 | "object": "text" 33 | } 34 | ], 35 | "object": "block", 36 | "type": "code_line" 37 | }, 38 | { 39 | "data": {}, 40 | "nodes": [ 41 | { 42 | "leaves": [ 43 | { 44 | "marks": [], 45 | "object": "leaf", 46 | "text": " \"test:code\": \"jest\"," 47 | } 48 | ], 49 | "object": "text" 50 | } 51 | ], 52 | "object": "block", 53 | "type": "code_line" 54 | }, 55 | { 56 | "data": {}, 57 | "nodes": [ 58 | { 59 | "leaves": [ 60 | { 61 | "marks": [], 62 | "object": "leaf", 63 | "text": " \"test:watch\": \"jest --watch\"," 64 | } 65 | ], 66 | "object": "text" 67 | } 68 | ], 69 | "object": "block", 70 | "type": "code_line" 71 | }, 72 | { 73 | "data": {}, 74 | "nodes": [ 75 | { 76 | "leaves": [ 77 | { 78 | "marks": [], 79 | "object": "leaf", 80 | "text": " \"test:eslint\": \"eslint .\"," 81 | } 82 | ], 83 | "object": "text" 84 | } 85 | ], 86 | "object": "block", 87 | "type": "code_line" 88 | }, 89 | { 90 | "data": {}, 91 | "nodes": [ 92 | { 93 | "leaves": [ 94 | { 95 | "marks": [], 96 | "object": "leaf", 97 | "text": " \"prettier\": \"prettier './**/**/**.{json,js,eslintrc}' --write\"," 98 | } 99 | ], 100 | "object": "text" 101 | } 102 | ], 103 | "object": "block", 104 | "type": "code_line" 105 | }, 106 | { 107 | "data": {}, 108 | "nodes": [ 109 | { 110 | "leaves": [ 111 | { 112 | "marks": [], 113 | "object": "leaf", 114 | "text": " \"build:prod\": \"NODE_ENV=production webpack-cli\"," 115 | } 116 | ], 117 | "object": "text" 118 | } 119 | ], 120 | "object": "block", 121 | "type": "code_line" 122 | }, 123 | { 124 | "data": {}, 125 | "nodes": [ 126 | { 127 | "leaves": [ 128 | { 129 | "marks": [], 130 | "object": "leaf", 131 | "text": " \"dev\": \"webpack-dev-server\"" 132 | } 133 | ], 134 | "object": "text" 135 | } 136 | ], 137 | "object": "block", 138 | "type": "code_line" 139 | }, 140 | { 141 | "data": {}, 142 | "nodes": [ 143 | { 144 | "leaves": [{ "marks": [], "object": "leaf", "text": " }," }], 145 | "object": "text" 146 | } 147 | ], 148 | "object": "block", 149 | "type": "code_line" 150 | }, 151 | { 152 | "data": {}, 153 | "nodes": [ 154 | { 155 | "leaves": [ 156 | { "marks": [], "object": "leaf", "text": " \"husky\": {" } 157 | ], 158 | "object": "text" 159 | } 160 | ], 161 | "object": "block", 162 | "type": "code_line" 163 | }, 164 | { 165 | "data": {}, 166 | "nodes": [ 167 | { 168 | "leaves": [ 169 | { "marks": [], "object": "leaf", "text": " \"hooks\": {" } 170 | ], 171 | "object": "text" 172 | } 173 | ], 174 | "object": "block", 175 | "type": "code_line" 176 | }, 177 | { 178 | "data": {}, 179 | "nodes": [ 180 | { 181 | "leaves": [ 182 | { 183 | "marks": [], 184 | "object": "leaf", 185 | "text": " \"pre-commit\": \"lint-staged\"," 186 | } 187 | ], 188 | "object": "text" 189 | } 190 | ], 191 | "object": "block", 192 | "type": "code_line" 193 | }, 194 | { 195 | "data": {}, 196 | "nodes": [ 197 | { 198 | "leaves": [ 199 | { 200 | "marks": [], 201 | "object": "leaf", 202 | "text": " \"pre-push\": \"npm run test:eslint\"" 203 | } 204 | ], 205 | "object": "text" 206 | } 207 | ], 208 | "object": "block", 209 | "type": "code_line" 210 | }, 211 | { 212 | "data": {}, 213 | "nodes": [ 214 | { 215 | "leaves": [{ "marks": [], "object": "leaf", "text": " }" }], 216 | "object": "text" 217 | } 218 | ], 219 | "object": "block", 220 | "type": "code_line" 221 | }, 222 | { 223 | "data": {}, 224 | "nodes": [ 225 | { 226 | "leaves": [{ "marks": [], "object": "leaf", "text": " }," }], 227 | "object": "text" 228 | } 229 | ], 230 | "object": "block", 231 | "type": "code_line" 232 | } 233 | ], 234 | "object": "block", 235 | "type": "code" 236 | }, 237 | { 238 | "data": {}, 239 | "nodes": [ 240 | { 241 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 242 | "object": "text" 243 | } 244 | ], 245 | "object": "block", 246 | "type": "paragraph" 247 | }, 248 | { 249 | "data": {}, 250 | "nodes": [ 251 | { 252 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 253 | "object": "text" 254 | } 255 | ], 256 | "object": "block", 257 | "type": "paragraph" 258 | }, 259 | { 260 | "data": {}, 261 | "nodes": [ 262 | { 263 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 264 | "object": "text" 265 | } 266 | ], 267 | "object": "block", 268 | "type": "paragraph" 269 | }, 270 | { 271 | "data": { "language": "javascript" }, 272 | "nodes": [ 273 | { 274 | "data": {}, 275 | "nodes": [ 276 | { 277 | "leaves": [ 278 | { 279 | "marks": [], 280 | "object": "leaf", 281 | "text": " // Handle the extra wrapping required for list buttons." 282 | } 283 | ], 284 | "object": "text" 285 | } 286 | ], 287 | "object": "block", 288 | "type": "code_line" 289 | }, 290 | { 291 | "data": {}, 292 | "nodes": [ 293 | { 294 | "leaves": [ 295 | { 296 | "marks": [], 297 | "object": "leaf", 298 | "text": " const isList = hasBlock(\"list-item\", props)" 299 | } 300 | ], 301 | "object": "text" 302 | } 303 | ], 304 | "object": "block", 305 | "type": "code_line" 306 | }, 307 | { 308 | "data": {}, 309 | "nodes": [ 310 | { 311 | "leaves": [ 312 | { 313 | "marks": [], 314 | "object": "leaf", 315 | "text": " const isType = value.blocks.some((block) =>" 316 | } 317 | ], 318 | "object": "text" 319 | } 320 | ], 321 | "object": "block", 322 | "type": "code_line" 323 | }, 324 | { 325 | "data": {}, 326 | "nodes": [ 327 | { 328 | "leaves": [ 329 | { 330 | "marks": [], 331 | "object": "leaf", 332 | "text": " Boolean(document.getClosest(block.key, (parent) => parent.type === type))," 333 | } 334 | ], 335 | "object": "text" 336 | } 337 | ], 338 | "object": "block", 339 | "type": "code_line" 340 | }, 341 | { 342 | "data": {}, 343 | "nodes": [ 344 | { 345 | "leaves": [{ "marks": [], "object": "leaf", "text": " )" }], 346 | "object": "text" 347 | } 348 | ], 349 | "object": "block", 350 | "type": "code_line" 351 | }, 352 | { 353 | "data": {}, 354 | "nodes": [ 355 | { 356 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 357 | "object": "text" 358 | } 359 | ], 360 | "object": "block", 361 | "type": "code_line" 362 | }, 363 | { 364 | "data": {}, 365 | "nodes": [ 366 | { 367 | "leaves": [ 368 | { 369 | "marks": [], 370 | "object": "leaf", 371 | "text": " unWrapBlocks(editor, [" 372 | } 373 | ], 374 | "object": "text" 375 | } 376 | ], 377 | "object": "block", 378 | "type": "code_line" 379 | }, 380 | { 381 | "data": {}, 382 | "nodes": [ 383 | { 384 | "leaves": [ 385 | { 386 | "marks": [], 387 | "object": "leaf", 388 | "text": " \"bulleted-list\"," 389 | } 390 | ], 391 | "object": "text" 392 | } 393 | ], 394 | "object": "block", 395 | "type": "code_line" 396 | }, 397 | { 398 | "data": {}, 399 | "nodes": [ 400 | { 401 | "leaves": [ 402 | { 403 | "marks": [], 404 | "object": "leaf", 405 | "text": " \"numbered-list\"," 406 | } 407 | ], 408 | "object": "text" 409 | } 410 | ], 411 | "object": "block", 412 | "type": "code_line" 413 | }, 414 | { 415 | "data": {}, 416 | "nodes": [ 417 | { 418 | "leaves": [ 419 | { 420 | "marks": [], 421 | "object": "leaf", 422 | "text": " \"block-quote\"," 423 | } 424 | ], 425 | "object": "text" 426 | } 427 | ], 428 | "object": "block", 429 | "type": "code_line" 430 | }, 431 | { 432 | "data": {}, 433 | "nodes": [ 434 | { 435 | "leaves": [ 436 | { "marks": [], "object": "leaf", "text": " \"code\"," } 437 | ], 438 | "object": "text" 439 | } 440 | ], 441 | "object": "block", 442 | "type": "code_line" 443 | }, 444 | { 445 | "data": {}, 446 | "nodes": [ 447 | { 448 | "leaves": [{ "marks": [], "object": "leaf", "text": " ])" }], 449 | "object": "text" 450 | } 451 | ], 452 | "object": "block", 453 | "type": "code_line" 454 | } 455 | ], 456 | "object": "block", 457 | "type": "code" 458 | }, 459 | { 460 | "data": {}, 461 | "nodes": [ 462 | { 463 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 464 | "object": "text" 465 | } 466 | ], 467 | "object": "block", 468 | "type": "paragraph" 469 | }, 470 | { 471 | "data": {}, 472 | "nodes": [ 473 | { 474 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 475 | "object": "text" 476 | } 477 | ], 478 | "object": "block", 479 | "type": "paragraph" 480 | }, 481 | { 482 | "data": {}, 483 | "nodes": [ 484 | { 485 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 486 | "object": "text" 487 | } 488 | ], 489 | "object": "block", 490 | "type": "paragraph" 491 | }, 492 | { 493 | "data": {}, 494 | "nodes": [ 495 | { 496 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 497 | "object": "text" 498 | } 499 | ], 500 | "object": "block", 501 | "type": "paragraph" 502 | }, 503 | { 504 | "data": {}, 505 | "nodes": [ 506 | { 507 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 508 | "object": "text" 509 | } 510 | ], 511 | "object": "block", 512 | "type": "paragraph" 513 | }, 514 | { 515 | "data": {}, 516 | "nodes": [ 517 | { 518 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 519 | "object": "text" 520 | } 521 | ], 522 | "object": "block", 523 | "type": "paragraph" 524 | }, 525 | { 526 | "data": {}, 527 | "nodes": [ 528 | { 529 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 530 | "object": "text" 531 | } 532 | ], 533 | "object": "block", 534 | "type": "paragraph" 535 | }, 536 | { 537 | "data": {}, 538 | "nodes": [ 539 | { 540 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 541 | "object": "text" 542 | } 543 | ], 544 | "object": "block", 545 | "type": "paragraph" 546 | }, 547 | { 548 | "data": {}, 549 | "nodes": [ 550 | { 551 | "data": {}, 552 | "nodes": [ 553 | { 554 | "leaves": [{ "marks": [], "object": "leaf", "text": "one " }], 555 | "object": "text" 556 | } 557 | ], 558 | "object": "block", 559 | "type": "list-item" 560 | }, 561 | { 562 | "data": {}, 563 | "nodes": [ 564 | { 565 | "leaves": [{ "marks": [], "object": "leaf", "text": "two" }], 566 | "object": "text" 567 | } 568 | ], 569 | "object": "block", 570 | "type": "list-item" 571 | } 572 | ], 573 | "object": "block", 574 | "type": "numbered-list" 575 | }, 576 | { 577 | "data": {}, 578 | "nodes": [ 579 | { 580 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 581 | "object": "text" 582 | } 583 | ], 584 | "object": "block", 585 | "type": "paragraph" 586 | }, 587 | { 588 | "data": {}, 589 | "nodes": [ 590 | { 591 | "data": {}, 592 | "nodes": [ 593 | { 594 | "leaves": [{ "marks": [], "object": "leaf", "text": "three" }], 595 | "object": "text" 596 | } 597 | ], 598 | "object": "block", 599 | "type": "list-item" 600 | } 601 | ], 602 | "object": "block", 603 | "type": "numbered-list" 604 | }, 605 | { 606 | "data": {}, 607 | "nodes": [ 608 | { 609 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 610 | "object": "text" 611 | } 612 | ], 613 | "object": "block", 614 | "type": "paragraph" 615 | }, 616 | { 617 | "data": {}, 618 | "nodes": [ 619 | { 620 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 621 | "object": "text" 622 | } 623 | ], 624 | "object": "block", 625 | "type": "paragraph" 626 | }, 627 | { 628 | "data": {}, 629 | "nodes": [ 630 | { 631 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 632 | "object": "text" 633 | } 634 | ], 635 | "object": "block", 636 | "type": "paragraph" 637 | }, 638 | { 639 | "data": {}, 640 | "nodes": [ 641 | { 642 | "data": {}, 643 | "nodes": [ 644 | { 645 | "leaves": [{ "marks": [], "object": "leaf", "text": "one-1" }], 646 | "object": "text" 647 | } 648 | ], 649 | "object": "block", 650 | "type": "list-item" 651 | }, 652 | { 653 | "data": {}, 654 | "nodes": [ 655 | { 656 | "leaves": [{ "marks": [], "object": "leaf", "text": "two-1" }], 657 | "object": "text" 658 | } 659 | ], 660 | "object": "block", 661 | "type": "list-item" 662 | } 663 | ], 664 | "object": "block", 665 | "type": "bulleted-list" 666 | }, 667 | { 668 | "data": {}, 669 | "nodes": [ 670 | { 671 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 672 | "object": "text" 673 | } 674 | ], 675 | "object": "block", 676 | "type": "paragraph" 677 | }, 678 | { 679 | "data": {}, 680 | "nodes": [ 681 | { 682 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 683 | "object": "text" 684 | } 685 | ], 686 | "object": "block", 687 | "type": "paragraph" 688 | }, 689 | { 690 | "data": {}, 691 | "nodes": [ 692 | { 693 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 694 | "object": "text" 695 | } 696 | ], 697 | "object": "block", 698 | "type": "paragraph" 699 | }, 700 | { 701 | "data": {}, 702 | "nodes": [ 703 | { 704 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 705 | "object": "text" 706 | } 707 | ], 708 | "object": "block", 709 | "type": "paragraph" 710 | }, 711 | { 712 | "data": {}, 713 | "nodes": [ 714 | { 715 | "leaves": [ 716 | { 717 | "marks": [ 718 | { "data": {}, "object": "mark", "type": "underlined" }, 719 | { "data": {}, "object": "mark", "type": "italic" }, 720 | { "data": {}, "object": "mark", "type": "bold" } 721 | ], 722 | "object": "leaf", 723 | "text": "bold text-test-ltialic . ret4eg4534regt34rw" 724 | } 725 | ], 726 | "object": "text" 727 | } 728 | ], 729 | "object": "block", 730 | "type": "paragraph" 731 | }, 732 | { 733 | "data": {}, 734 | "nodes": [ 735 | { 736 | "leaves": [ 737 | { 738 | "marks": [ 739 | { "data": {}, "object": "mark", "type": "underlined" }, 740 | { "data": {}, "object": "mark", "type": "italic" }, 741 | { "data": {}, "object": "mark", "type": "bold" } 742 | ], 743 | "object": "leaf", 744 | "text": "" 745 | } 746 | ], 747 | "object": "text" 748 | } 749 | ], 750 | "object": "block", 751 | "type": "paragraph" 752 | }, 753 | { 754 | "data": {}, 755 | "nodes": [ 756 | { 757 | "leaves": [ 758 | { 759 | "marks": [ 760 | { "data": {}, "object": "mark", "type": "underlined" }, 761 | { "data": {}, "object": "mark", "type": "italic" }, 762 | { "data": {}, "object": "mark", "type": "bold" } 763 | ], 764 | "object": "leaf", 765 | "text": "" 766 | } 767 | ], 768 | "object": "text" 769 | } 770 | ], 771 | "object": "block", 772 | "type": "paragraph" 773 | }, 774 | { 775 | "data": {}, 776 | "nodes": [ 777 | { 778 | "leaves": [ 779 | { 780 | "marks": [ 781 | { "data": {}, "object": "mark", "type": "underlined" }, 782 | { "data": {}, "object": "mark", "type": "italic" }, 783 | { "data": {}, "object": "mark", "type": "bold" } 784 | ], 785 | "object": "leaf", 786 | "text": "" 787 | } 788 | ], 789 | "object": "text" 790 | } 791 | ], 792 | "object": "block", 793 | "type": "paragraph" 794 | }, 795 | { 796 | "data": {}, 797 | "nodes": [ 798 | { 799 | "leaves": [ 800 | { 801 | "marks": [ 802 | { "data": {}, "object": "mark", "type": "bold" }, 803 | { "data": {}, "object": "mark", "type": "italic" } 804 | ], 805 | "object": "leaf", 806 | "text": "43" 807 | } 808 | ], 809 | "object": "text" 810 | } 811 | ], 812 | "object": "block", 813 | "type": "paragraph" 814 | }, 815 | { 816 | "data": {}, 817 | "nodes": [ 818 | { 819 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 820 | "object": "text" 821 | } 822 | ], 823 | "object": "block", 824 | "type": "paragraph" 825 | }, 826 | { 827 | "data": {}, 828 | "nodes": [ 829 | { 830 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 831 | "object": "text" 832 | } 833 | ], 834 | "object": "block", 835 | "type": "paragraph" 836 | }, 837 | { 838 | "data": {}, 839 | "nodes": [ 840 | { 841 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 842 | "object": "text" 843 | } 844 | ], 845 | "object": "block", 846 | "type": "paragraph" 847 | }, 848 | { 849 | "data": {}, 850 | "nodes": [ 851 | { 852 | "data": {}, 853 | "nodes": [ 854 | { 855 | "leaves": [ 856 | { "marks": [], "object": "leaf", "text": "three-1" } 857 | ], 858 | "object": "text" 859 | } 860 | ], 861 | "object": "block", 862 | "type": "list-item" 863 | } 864 | ], 865 | "object": "block", 866 | "type": "bulleted-list" 867 | }, 868 | { 869 | "data": {}, 870 | "nodes": [ 871 | { 872 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 873 | "object": "text" 874 | } 875 | ], 876 | "object": "block", 877 | "type": "paragraph" 878 | }, 879 | { 880 | "data": {}, 881 | "nodes": [ 882 | { 883 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 884 | "object": "text" 885 | } 886 | ], 887 | "object": "block", 888 | "type": "paragraph" 889 | }, 890 | { 891 | "data": {}, 892 | "nodes": [ 893 | { 894 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 895 | "object": "text" 896 | } 897 | ], 898 | "object": "block", 899 | "type": "paragraph" 900 | }, 901 | { 902 | "data": {}, 903 | "nodes": [ 904 | { 905 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 906 | "object": "text" 907 | } 908 | ], 909 | "object": "block", 910 | "type": "paragraph" 911 | }, 912 | { 913 | "data": {}, 914 | "nodes": [ 915 | { 916 | "data": {}, 917 | "nodes": [ 918 | { 919 | "leaves": [ 920 | { 921 | "marks": [], 922 | "object": "leaf", 923 | "text": "цитатацитатацитатацитатацитатацитатацитатацитатацитата" 924 | } 925 | ], 926 | "object": "text" 927 | } 928 | ], 929 | "object": "block", 930 | "type": "paragraph" 931 | }, 932 | { 933 | "data": {}, 934 | "nodes": [ 935 | { 936 | "leaves": [ 937 | { 938 | "marks": [], 939 | "object": "leaf", 940 | "text": "цитатацитатацитатацитатацитатацитатацитатацитата" 941 | } 942 | ], 943 | "object": "text" 944 | } 945 | ], 946 | "object": "block", 947 | "type": "paragraph" 948 | }, 949 | { 950 | "data": {}, 951 | "nodes": [ 952 | { 953 | "leaves": [{ "marks": [], "object": "leaf", "text": "" }], 954 | "object": "text" 955 | } 956 | ], 957 | "object": "block", 958 | "type": "paragraph" 959 | }, 960 | { 961 | "data": {}, 962 | "nodes": [ 963 | { 964 | "leaves": [ 965 | { 966 | "marks": [], 967 | "object": "leaf", 968 | "text": "цитатацитатацитата" 969 | } 970 | ], 971 | "object": "text" 972 | } 973 | ], 974 | "object": "block", 975 | "type": "paragraph" 976 | } 977 | ], 978 | "object": "block", 979 | "type": "block-quote" 980 | } 981 | ], 982 | "object": "document" 983 | }, 984 | "object": "value" 985 | } 986 | --------------------------------------------------------------------------------