diff --git a/.gitignore b/.gitignore index ea8c4bf..d9811f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target +gunnhildr.db +gunnhildr.db-shm +gunnhildr.db-wal \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 13414d2..0833cd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,6 +243,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.18" @@ -382,6 +397,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + [[package]] name = "bytemuck" version = "1.20.0" @@ -426,6 +447,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.6", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -464,6 +499,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.16" @@ -822,6 +863,7 @@ name = "gunnhildr" version = "0.1.0" dependencies = [ "actix-web", + "chrono", "env_logger", "figment", "log", @@ -942,6 +984,29 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -1124,6 +1189,16 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -2282,6 +2357,60 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasm-bindgen" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" + [[package]] name = "whoami" version = "1.5.2" @@ -2292,6 +2421,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 30d497e..bfc9501 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,10 @@ edition = "2021" [dependencies] actix-web = {version="4.9.0"} +chrono = "0.4.38" env_logger = "0.11.5" figment = {version="0.10.19", features=["env"]} log = "0.4.22" serde = {version="1.0.215", features=["derive"]} -sqlx = {version="0.8.2", features=["runtime-tokio"]} +sqlx = {version="0.8.2", features=["runtime-tokio","postgres", "sqlite"]} tokio = "1.42.0" diff --git a/migrations/sqlite/20241207082654_initial_table_setup.sql b/migrations/sqlite/20241207082654_initial_table_setup.sql new file mode 100644 index 0000000..4f90b11 --- /dev/null +++ b/migrations/sqlite/20241207082654_initial_table_setup.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS users( + user_id INTEGER PRIMARY KEY, + name TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS books( + book_id INTEGER PRIMARY KEY, + book_title TEXT NOT NULL, + book_description TEXT, + book_creation_date INT NOT NULL, + author_id INTEGER NOT NULL, + FOREIGN KEY (author_id) REFERENCES users(user_id) ON DELETE CASCADE + +); + +CREATE TABLE IF NOT EXISTS chapters( + chapter_id INTEGER PRIMARY KEY, + chapter_title TEXT NOT NULL, + chapter_text TEXT NOT NULL, + chapter_creation_date INT NOT NULL, + book_id INTEGER NOT NULL, + author_id INTEGER NOT NULL, + FOREIGN KEY (book_id) REFERENCES books(book_id) ON DELETE CASCADE, + FOREIGN KEY (author_id) REFERENCES users(user_id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..2d07234 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,46 @@ +use actix_web::{ + get, + web::{self, Json, Redirect}, + Scope, +}; +use serde::Deserialize; + +use crate::db::DbInterface; + +pub fn api_scope() -> Scope { + web::scope("/api") + .service(create_book) + .service(create_chapter) + .service(create_user) +} + +#[derive(Deserialize)] +struct BookForm { + title: String, + description: String, +} + +#[get("/create/book")] +async fn create_book(Json(form): Json, db: web::Data) -> Redirect { + let id = db + .create_book(&form.title, &form.description, todo!()) + .await + .unwrap(); + + Redirect::to(format!("r/b/{}", id)).permanent() +} + +#[get("/create/chapter")] +async fn create_chapter() -> String { + todo!() +} + +#[derive(Deserialize)] +struct UserForm { + name: String, +} + +#[get("/create/user")] +async fn create_user(web::Form(form): web::Form, db: web::Data) -> String { + todo!() +} diff --git a/src/config.rs b/src/config.rs index 88637bd..f5dc496 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,9 +5,11 @@ use figment::{ use serde::{Deserialize, Serialize}; use std::net::{IpAddr, Ipv4Addr}; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone, Copy)] pub struct Config { + /// Ip address that the gunnhildr should bind to pub binding_ip: IpAddr, + /// Port that gunnhildr should listen on pub port: u16, } @@ -20,6 +22,7 @@ impl Default for Config { } } +/// Parse and merge all config sources pub fn parse_config() -> Config { Figment::from(Serialized::defaults(Config::default())) .merge(Env::prefixed("HILDR")) diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..c058993 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,151 @@ +#![allow(unused)] +use log::{info, warn}; +use sqlx::{migrate::MigrateDatabase, PgPool, Postgres, Sqlite, SqlitePool}; + +pub mod models; +mod postgres; +mod sqlite; + +/// Utility for interacting with the database +#[derive(Clone)] +pub enum DbInterface { + /// Used for Sqlite database + Sqlite(sqlx::Pool), + /// Used for Postgres database + Postgres(sqlx::Pool), +} + +/// Error type for handling DB related errors +#[derive(Debug)] +pub enum DbError { + /// No such entry found + NotFound, + /// Database related error + SqlxError(sqlx::Error), +} + +impl From for DbError { + fn from(value: sqlx::Error) -> Self { + match value { + sqlx::Error::RowNotFound => DbError::NotFound, + _ => DbError::SqlxError(value), + } + } +} +type DbResult = Result; + +impl DbInterface { + /// Database backed by SQLite + pub async fn sqlite() -> Self { + // Check if db exists, if not create it. + if !sqlx::Sqlite::database_exists("sqlite:gunnhildr.db") + .await + .expect("failed to connect to db") + { + warn!("No SQLite database found, if this is the first time you are starting Gunnhildr, you can safely ignore this."); + sqlx::Sqlite::create_database("sqlite:gunnhildr.db") + .await + .expect("failed to create SQLite Database"); + info!("Created new SQLite Database"); + } + + let pool = SqlitePool::connect("sqlite:gunnhildr.db").await.unwrap(); + + // run migrations + sqlx::migrate!("migrations/sqlite") + .run(&pool) + .await + .expect("Failed to apply migration!"); + info!("Applied migrations."); + + Self::Sqlite(pool) + } + + /// Database backed by Postgres + pub async fn postgres(url: &str) -> Self { + // check if database exists and create one if not + if !sqlx::Postgres::database_exists(url) + .await + .expect("failed to connect to db") + { + warn!("No Postgres database found, if this is the first time you are starting Gunnhildr, you can safely ignore this."); + sqlx::Postgres::create_database(url) + .await + .expect("failed to create Postgres Database!"); + info!("Created new Postgres Database"); + } + + let pool = PgPool::connect("url").await.unwrap(); + + // run migrations + sqlx::migrate!("migrations/postgres") + .run(&pool) + .await + .expect("Failed to apply migration!"); + + Self::Postgres(pool) + } + + /// Tries to fetch a book from the database + pub async fn get_book(&self, id: u32) -> DbResult { + match self { + DbInterface::Sqlite(pool) => sqlite::sqlite_book(pool, id).await, + DbInterface::Postgres(pool) => todo!(), + } + } + + /// Tries to create a book and returns the book's id if successful + pub async fn create_book( + &self, + title: &String, + description: &String, + author_id: u32, + ) -> DbResult { + match self { + DbInterface::Sqlite(pool) => { + sqlite::sqlite_book_create(pool, title, description, author_id).await + } + DbInterface::Postgres(pool) => todo!(), + } + } + + /// Tries to fetch a chapter from the database + pub async fn get_chapter(&self, id: u32) -> DbResult { + match self { + DbInterface::Sqlite(pool) => sqlite::sqlite_chapter(pool, id).await, + DbInterface::Postgres(pool) => todo!(), + } + } + + /// Tries to create a chapter and returns the chapter's id if successful + pub async fn create_chapter( + &self, + title: &String, + text: &String, + book_id: u32, + author_id: u32, + ) -> DbResult { + match self { + DbInterface::Sqlite(pool) => { + sqlite::sqlite_chapter_create(pool, title, text, book_id, author_id).await + } + DbInterface::Postgres(pool) => todo!(), + } + } + + /// Tries to fetch a user from the database + pub async fn get_user(&self, id: u32) -> DbResult { + match self { + DbInterface::Sqlite(pool) => sqlite::sqlite_user(pool, id).await, + DbInterface::Postgres(pool) => todo!(), + } + } + + /// Tries to create a user and returns the user's id if successful + pub async fn create_user(&self, name: &String) -> DbResult { + match self { + DbInterface::Sqlite(pool) => sqlite::sqlite_user_create(pool, name).await, + DbInterface::Postgres(pool) => todo!(), + } + } +} diff --git a/src/db/models.rs b/src/db/models.rs new file mode 100644 index 0000000..51feae0 --- /dev/null +++ b/src/db/models.rs @@ -0,0 +1,21 @@ +pub struct Book { + pub id: u32, + pub title: String, + pub description: String, + pub creation_date: String, + pub author_id: u32, +} + +pub struct Chapter { + pub id: u32, + pub title: String, + pub text: String, + pub creation_date: String, + pub book_id: u32, + pub author_id: u32, +} + +pub struct User { + pub id: u32, + pub name: String, +} diff --git a/src/db/postgres.rs b/src/db/postgres.rs new file mode 100644 index 0000000..624925e --- /dev/null +++ b/src/db/postgres.rs @@ -0,0 +1 @@ +//! Module containing database code for Postgres diff --git a/src/db/sqlite.rs b/src/db/sqlite.rs new file mode 100644 index 0000000..1f52e46 --- /dev/null +++ b/src/db/sqlite.rs @@ -0,0 +1,99 @@ +//! Module containing database code for SQLite +use super::models::{Book, Chapter, User}; +use super::{DbError, DbResult}; +use serde::de; +use sqlx::{Pool, Row, Sqlite}; + +pub async fn sqlite_book(pool: &Pool, id: u32) -> DbResult { + let row = sqlx::query("SELECT * FROM users WHERE user_id = ?") + .bind(id) + .fetch_one(pool) + .await?; + + let book = Book { + id, + title: row.get("book_title"), + description: row.get("book_description"), + creation_date: row.get("book_creation_date"), + author_id: row.get("author_id"), + }; + + Ok(book) +} + +pub async fn sqlite_book_create( + pool: &Pool, + title: &String, + description: &String, + author_id: u32, +) -> DbResult { + let id = sqlx::query("INSERT INTO books (title, description, author_id, book_creation_date) VALUES ( ?1, ?2, ?3, ?4 )") + .bind(title) + .bind(description) + .bind(author_id) + .bind(chrono::Local::now().timestamp()) + .execute(pool) + .await? + .last_insert_rowid() as u32; + Ok(id) +} + +pub async fn sqlite_chapter(pool: &Pool, id: u32) -> DbResult { + let row = sqlx::query("SELECT * FROM chapters WHERE chapter_id = ?") + .bind(id) + .fetch_one(pool) + .await?; + + let chapter = Chapter { + id, + title: row.get("chapter_title"), + text: row.get("chapter_text"), + creation_date: row.get("chapter_creation_date"), + book_id: row.get("book_id"), + author_id: row.get("author_id"), + }; + Ok(chapter) +} + +pub async fn sqlite_chapter_create( + pool: &Pool, + title: &String, + text: &String, + book_id: u32, + author_id: u32, +) -> DbResult { + let id = sqlx::query("INSERT INTO chapters (chapter_title, chapter_text, book_id, author_id, chapter_creation_date) VALUES ( ?1, ?2, ?3, ?4, ?5 )") + .bind(title) + .bind(text) + .bind(book_id) + .bind(author_id) + .bind(chrono::Local::now().timestamp()) + .execute(pool) + .await? + .last_insert_rowid() as u32; + + Ok(id) +} + +pub async fn sqlite_user(pool: &Pool, id: u32) -> DbResult { + let row = sqlx::query("SELECT * FROM users WHERE user_id = ?") + .bind(id) + .fetch_one(pool) + .await?; + + let user = User { + id, + name: row.get("name"), + }; + + Ok(user) +} + +pub async fn sqlite_user_create(pool: &Pool, name: &String) -> DbResult { + let id = sqlx::query("INSERT INTO users (name) VALUES ( ? )") + .bind(name) + .execute(pool) + .await? + .last_insert_rowid() as u32; + Ok(id) +} diff --git a/src/main.rs b/src/main.rs index 5a8fc1e..3470a57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,32 @@ -use actix_web::{get, App, HttpServer}; +use actix_web::{get, web, App, HttpServer}; use log::info; +mod api; mod config; +mod db; +mod reading; #[actix_web::main] async fn main() -> Result<(), std::io::Error> { - env_logger::init(); + // init env logger + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .init(); let config = config::parse_config(); + let db = db::DbInterface::sqlite().await; info!("Server starting..."); - HttpServer::new(|| App::new().service(hello)) - .bind((config.binding_ip, config.port))? - .run() - .await + HttpServer::new(move || { + App::new() + .app_data(web::Data::new(config)) + .app_data(web::Data::new(db.clone())) + .service(reading::reading_scope()) + .service(hello) + }) + .bind((config.binding_ip, config.port))? + .run() + .await } #[get("/hello")] diff --git a/src/reading/mod.rs b/src/reading/mod.rs new file mode 100644 index 0000000..f6bdc3c --- /dev/null +++ b/src/reading/mod.rs @@ -0,0 +1,24 @@ +use actix_web::{get, web, Scope}; + +use crate::db::DbInterface; + +/// scope to handle all reading related pages +pub fn reading_scope() -> Scope { + web::scope("/r").service(book_view).service(chapter_view) +} + +/// route to view info for a specific book +#[get("/b/{book}")] +async fn book_view(book: web::Path, db: web::Data) -> Option { + Some(format!( + "This is the info for {}", + db.get_book(*book).await.ok()?.title + )) +} + +/// view for reading a chapter +#[get("/c/{chapter})")] +async fn chapter_view(id: web::Path, db: web::Data) -> Option { + let chapter = db.get_chapter(*id).await.ok()?; + Some(format!("Text: {}", chapter.text)) +}