From 3ad5e97b759583c6645d18e5f9453a7b645ebb96 Mon Sep 17 00:00:00 2001
From: Antoine Gersant <antoine.gersant@lesforges.org>
Date: Tue, 7 Jan 2025 21:51:43 -0800
Subject: [PATCH] Settings and auth secret migration

---
 Cargo.lock          |  59 ++++++++++++++++++++
 Cargo.toml          |   1 +
 src/app.rs          |  59 +++++++++++++++++++-
 src/app/legacy.rs   | 128 +++++++++++++++++++++++++++++++++++++++++++-
 src/server/error.rs |   2 +
 5 files changed, 245 insertions(+), 4 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 43877a2..ad774c9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -620,6 +620,18 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
 
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
 [[package]]
 name = "fastrand"
 version = "2.1.0"
@@ -840,6 +852,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown",
+]
+
 [[package]]
 name = "headers"
 version = "0.4.0"
@@ -1268,6 +1289,17 @@ dependencies = [
  "redox_syscall",
 ]
 
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
 [[package]]
 name = "linux-raw-sys"
 version = "0.4.14"
@@ -1695,6 +1727,12 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
 
+[[package]]
+name = "pkg-config"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
+
 [[package]]
 name = "png"
 version = "0.17.13"
@@ -1750,6 +1788,7 @@ dependencies = [
  "rand",
  "rayon",
  "regex",
+ "rusqlite",
  "sd-notify",
  "serde",
  "serde_derive",
@@ -1981,6 +2020,20 @@ dependencies = [
  "windows-sys 0.52.0",
 ]
 
+[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags 2.6.0",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
 [[package]]
 name = "rust-multipart-rfc7578_2"
 version = "0.6.1"
@@ -2920,6 +2973,12 @@ version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
 
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
 [[package]]
 name = "version_check"
 version = "0.9.5"
diff --git a/Cargo.toml b/Cargo.toml
index 133113e..9e7f816 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,6 +41,7 @@ pbkdf2 = "0.11"
 rand = "0.8"
 rayon = "1.10.0"
 regex = "1.10.5"
+rusqlite = { version = "0.32.0", features = ["bundled"] }
 serde = { version = "1.0.147", features = ["derive"] }
 serde_derive = "1.0.147"
 serde_json = "1.0.122"
diff --git a/src/app.rs b/src/app.rs
index d2479aa..8500ed4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -6,6 +6,8 @@ use std::time::Duration;
 use log::info;
 use rand::rngs::OsRng;
 use rand::RngCore;
+use tokio::fs::try_exists;
+use tokio::task::spawn_blocking;
 
 use crate::app::legacy::*;
 use crate::paths::Paths;
@@ -37,6 +39,8 @@ pub enum Error {
 	#[error(transparent)]
 	FileWatch(#[from] notify::Error),
 	#[error(transparent)]
+	SQL(#[from] rusqlite::Error),
+	#[error(transparent)]
 	Ape(#[from] ape::Error),
 	#[error("ID3 error in `{0}`: `{1}`")]
 	Id3(PathBuf, id3::Error),
@@ -101,6 +105,8 @@ pub enum Error {
 	#[error("Could not serialize collection")]
 	IndexSerializationError,
 
+	#[error("Invalid Directory")]
+	InvalidDirectory(String),
 	#[error("The following virtual path could not be mapped to a real path: `{0}`")]
 	CouldNotMapToRealPath(PathBuf),
 	#[error("User not found")]
@@ -177,6 +183,7 @@ impl App {
 			.map_err(|e| Error::Io(thumbnails_dir_path.clone(), e))?;
 
 		let auth_secret_file_path = paths.data_dir_path.join("auth.secret");
+		Self::migrate_legacy_auth_secret(&paths.db_file_path, &auth_secret_file_path).await?;
 		let auth_secret = Self::get_or_create_auth_secret(&auth_secret_file_path).await?;
 
 		let config_manager = config::Manager::new(&paths.config_file_path, auth_secret).await?;
@@ -206,13 +213,61 @@ impl App {
 		Ok(app)
 	}
 
+	async fn migrate_legacy_auth_secret(
+		db_file_path: &PathBuf,
+		secret_file_path: &PathBuf,
+	) -> Result<(), Error> {
+		if !try_exists(db_file_path)
+			.await
+			.map_err(|e| Error::Io(db_file_path.clone(), e))?
+		{
+			return Ok(());
+		}
+
+		if try_exists(secret_file_path)
+			.await
+			.map_err(|e| Error::Io(secret_file_path.clone(), e))?
+		{
+			return Ok(());
+		}
+
+		info!(
+			"Migrating auth secret from database at `{}`",
+			db_file_path.to_string_lossy()
+		);
+
+		let secret = spawn_blocking({
+			let db_file_path = db_file_path.clone();
+			move || read_legacy_auth_secret(&db_file_path)
+		})
+		.await??;
+
+		tokio::fs::write(secret_file_path, &secret)
+			.await
+			.map_err(|e| Error::Io(secret_file_path.clone(), e))?;
+
+		Ok(())
+	}
+
 	async fn migrate_legacy_db(&self, db_file_path: &PathBuf) -> Result<(), Error> {
-		let Some(config) = read_legacy_config(db_file_path)? else {
+		if !try_exists(db_file_path)
+			.await
+			.map_err(|e| Error::Io(db_file_path.clone(), e))?
+		{
+			return Ok(());
+		}
+
+		let Some(config) = tokio::task::spawn_blocking({
+			let db_file_path = db_file_path.clone();
+			move || read_legacy_config(&db_file_path)
+		})
+		.await??
+		else {
 			return Ok(());
 		};
 
 		info!(
-			"Found usable data in legacy database at `{}`, beginning migration process",
+			"Found usable config in legacy database at `{}`, beginning migration process",
 			db_file_path.to_string_lossy()
 		);
 
diff --git a/src/app/legacy.rs b/src/app/legacy.rs
index aec274b..ae06b58 100644
--- a/src/app/legacy.rs
+++ b/src/app/legacy.rs
@@ -1,11 +1,68 @@
-use std::path::PathBuf;
+use std::{path::PathBuf, str::FromStr};
+
+use rusqlite::Connection;
 
 use crate::app::{config, index, Error};
 
+pub fn read_legacy_auth_secret(db_file_path: &PathBuf) -> Result<[u8; 32], Error> {
+	let connection = Connection::open(db_file_path)?;
+	let auth_secret: [u8; 32] =
+		connection.query_row("SELECT auth_secret FROM misc_settings", [], |row| {
+			row.get(0)
+		})?;
+	Ok(auth_secret)
+}
+
 pub fn read_legacy_config(
 	db_file_path: &PathBuf,
 ) -> Result<Option<config::storage::Config>, Error> {
-	Ok(None)
+	let connection = Connection::open(db_file_path)?;
+
+	// Album art pattern
+	let album_art_pattern: String = connection.query_row(
+		"SELECT index_album_art_pattern FROM misc_settings",
+		[],
+		|row| row.get(0),
+	)?;
+
+	// Mount directories
+	let mut mount_dirs_statement = connection.prepare("SELECT source, name FROM mount_points")?;
+	let mount_dirs_rows = mount_dirs_statement.query_and_then([], |row| {
+		let source_string = row.get::<_, String>(0)?;
+		let Ok(source) = PathBuf::from_str(&source_string) else {
+			return Err(Error::InvalidDirectory(source_string));
+		};
+		Ok(config::storage::MountDir {
+			source,
+			name: row.get::<_, String>(1)?,
+		})
+	})?;
+	let mut mount_dirs = vec![];
+	for mount_dir_result in mount_dirs_rows {
+		mount_dirs.push(mount_dir_result?);
+	}
+
+	// Users
+	let mut users_statement = connection.prepare("SELECT name, password_hash, admin FROM users")?;
+	let users_rows = users_statement.query_map([], |row| {
+		Ok(config::storage::User {
+			name: row.get(0)?,
+			admin: row.get(2)?,
+			initial_password: None,
+			hashed_password: row.get(1)?,
+		})
+	})?;
+	let mut users = vec![];
+	for user_result in users_rows {
+		users.push(user_result?);
+	}
+
+	Ok(Some(config::storage::Config {
+		album_art_pattern: Some(album_art_pattern),
+		mount_dirs,
+		ddns_update_url: None,
+		users,
+	}))
 }
 
 pub fn read_legacy_playlists(
@@ -22,3 +79,70 @@ pub async fn delete_legacy_db(db_file_path: &PathBuf) -> Result<(), Error> {
 		.map_err(|e| Error::Io(db_file_path.clone(), e))?;
 	Ok(())
 }
+
+#[cfg(test)]
+mod test {
+	use std::path::PathBuf;
+
+	use super::*;
+	use crate::app::config;
+
+	#[test]
+	fn can_read_auth_secret() {
+		let secret =
+			read_legacy_auth_secret(&PathBuf::from_iter(["test-data", "legacy_db_blank.sqlite"]))
+				.unwrap();
+		assert_eq!(
+			secret,
+			[
+				0x8b as u8, 0x88, 0x50, 0x17, 0x20, 0x09, 0x7e, 0x60, 0x31, 0x80, 0xCE, 0xE3, 0xF0,
+				0x5A, 0x00, 0xBC, 0x3A, 0xF4, 0xDC, 0xFD, 0x2E, 0xB7, 0x5D, 0x33, 0x5D, 0x81, 0x2F,
+				0x9A, 0xB4, 0x3A, 0x27, 0x2D
+			]
+		);
+	}
+
+	#[test]
+	fn can_read_blank_config() {
+		let actual =
+			read_legacy_config(&PathBuf::from_iter(["test-data", "legacy_db_blank.sqlite"]))
+				.unwrap()
+				.unwrap();
+
+		let expected = config::storage::Config {
+			album_art_pattern: Some("Folder.(jpeg|jpg|png)".to_owned()),
+			mount_dirs: vec![],
+			ddns_update_url: None,
+			users: vec![],
+		};
+
+		assert_eq!(actual, expected);
+	}
+
+	#[test]
+	fn can_read_populated_config() {
+		let actual = read_legacy_config(&PathBuf::from_iter([
+			"test-data",
+			"legacy_db_populated.sqlite",
+		]))
+		.unwrap()
+		.unwrap();
+
+		let expected = config::storage::Config {
+			album_art_pattern: Some("Folder.(jpeg|jpg|png)".to_owned()),
+			mount_dirs: vec![config::storage::MountDir {
+				source: PathBuf::from_iter(["/", "home", "agersant", "music", "Electronic", "Bitpop"]),
+				name: "root".to_owned(),
+			}],
+			ddns_update_url: None,
+			users: vec![config::storage::User {
+				name: "example_user".to_owned(),
+				admin: Some(true),
+				initial_password: None,
+				hashed_password: Some("$pbkdf2-sha256$i=10000,l=32$feX5cP9SyQrZdBZsOQfO3Q$vqdraNc8ecco+CdFr+2Vp+PcIK6R75rs72YovNCwd7s".to_owned()),
+			}],
+		};
+
+		assert_eq!(actual, expected);
+	}
+}
diff --git a/src/server/error.rs b/src/server/error.rs
index 8eaded9..11245f4 100644
--- a/src/server/error.rs
+++ b/src/server/error.rs
@@ -92,6 +92,8 @@ impl From<app::Error> for APIError {
 			app::Error::ThreadJoining(_) => APIError::Internal,
 
 			app::Error::Io(p, e) => APIError::Io(p, e),
+			app::Error::InvalidDirectory(_) => APIError::Internal,
+			app::Error::SQL(_) => APIError::Internal,
 			app::Error::FileWatch(_) => APIError::Internal,
 			app::Error::Ape(_) => APIError::Internal,
 			app::Error::Id3(p, e) => APIError::ThumbnailId3Decoding(p, e),