diff --git a/notto-server/migrations/V1__init.sql b/notto-server/migrations/V1__init.sql new file mode 100644 index 0000000..a2fc822 --- /dev/null +++ b/notto-server/migrations/V1__init.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS `user` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `username` VARCHAR(255) NOT NULL, + `stored_password_hash` TEXT NOT NULL, + `stored_recovery_hash` TEXT NOT NULL, + `encrypted_mek_password` BLOB NOT NULL, + `mek_password_nonce` BLOB NOT NULL, + `encrypted_mek_recovery` BLOB NOT NULL, + `mek_recovery_nonce` BLOB NOT NULL, + `salt_auth` VARCHAR(255) NOT NULL, + `salt_data` VARCHAR(255) NOT NULL, + `salt_recovery_auth` VARCHAR(255) NOT NULL, + `salt_recovery_data` VARCHAR(255) NOT NULL, + `salt_server_auth` VARCHAR(255) NOT NULL, + `salt_server_recovery` VARCHAR(255) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_username` (`username`) +); + +CREATE TABLE IF NOT EXISTS `user_token` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `id_user` INT UNSIGNED NOT NULL, + `token` BLOB NOT NULL, + PRIMARY KEY (`id`), + FOREIGN KEY (`id_user`) REFERENCES `user` (`id`) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS `note` ( + `uuid` VARCHAR(36) NOT NULL, + `id_user` INT UNSIGNED NOT NULL, + `content` MEDIUMBLOB NOT NULL, + `nonce` BLOB NOT NULL, + `metadata` BLOB NOT NULL, + `metadata_nonce` BLOB NOT NULL, + `updated_at` BIGINT NOT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`uuid`, `id_user`), + FOREIGN KEY (`id_user`) REFERENCES `user` (`id`) ON DELETE CASCADE +); diff --git a/notto-server/src/main.rs b/notto-server/src/main.rs index 062c391..01d6b34 100644 --- a/notto-server/src/main.rs +++ b/notto-server/src/main.rs @@ -17,6 +17,8 @@ use crate::schema::User; mod schema; +mod migrations; + /// Application error returned by all handlers. /// Internal errors are logged server-side and return a generic 500 to the client. pub struct AppError { @@ -82,7 +84,7 @@ impl From for AppError { } #[tokio::main] -async fn main() { +async fn main() -> anyhow::Result<()> { dotenv().ok(); //Env var should be like mysql://user:pass%20word@localhost/database_name let pool = Pool::new( @@ -91,6 +93,17 @@ async fn main() { .as_str(), ); + let mut conn = pool + .get_conn() + .await + .context("Failed to get DB connection for migrations")?; + + migrations::run(&mut conn) + .await + .context("Failed to run database migrations")?; + + drop(conn); + let app = Router::new() .route("/notes", post(send_notes)) .route("/notes", get(select_notes)) @@ -112,6 +125,8 @@ async fn main() { axum::serve(listener, app) .await .expect("Server error"); + + Ok(()) } /// Verifies that `token` matches one of the stored tokens for `username`. diff --git a/notto-server/src/migrations.rs b/notto-server/src/migrations.rs new file mode 100644 index 0000000..a8eff5a --- /dev/null +++ b/notto-server/src/migrations.rs @@ -0,0 +1,53 @@ +use anyhow::{Context, Result}; +use mysql_async::{Conn, params, prelude::Queryable}; + +/// Each migration is a (version, sql) pair. Version must be monotonically increasing. +/// Append new entries here to add future migrations; never edit existing ones. +static MIGRATIONS: &[(u32, &str)] = &[ + (1, include_str!("../migrations/V1__init.sql")), +]; + +/// Creates the tracking table if absent, then runs every migration whose version +/// is not yet recorded, in order. +pub async fn run(conn: &mut Conn) -> Result<()> { + conn.query_drop( + "CREATE TABLE IF NOT EXISTS `schema_migrations` ( + `version` INT UNSIGNED NOT NULL, + `applied_at` BIGINT NOT NULL, + PRIMARY KEY (`version`) + )", + ) + .await + .context("Failed to create schema_migrations table")?; + + let applied: Vec = conn + .query("SELECT version FROM schema_migrations ORDER BY version") + .await + .context("Failed to query applied migrations")?; + + for (version, sql) in MIGRATIONS { + if applied.contains(version) { + continue; + } + + for statement in sql.split(';').map(str::trim).filter(|s| !s.is_empty()) { + conn.query_drop(statement) + .await + .with_context(|| format!("Migration V{version} failed on statement: {statement}"))?; + } + + conn.exec_drop( + "INSERT INTO schema_migrations (version, applied_at) VALUES (:version, :applied_at)", + params! { + "version" => version, + "applied_at" => chrono::Local::now().to_utc().timestamp(), + }, + ) + .await + .with_context(|| format!("Failed to record migration V{version}"))?; + + println!("Applied migration V{version}"); + } + + Ok(()) +}