diff --git a/Cargo.lock b/Cargo.lock index 6a72e09..eb7d2a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,6 +333,15 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +[[package]] +name = "async-tempfile" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8a57b75c36e16f4d015e60e6a177552976a99b6947724403c551bcfa7cb1207" +dependencies = [ + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -3655,6 +3664,7 @@ version = "0.1.0" dependencies = [ "askama", "askama_web", + "async-tempfile", "axum", "camino", "chrono", @@ -3667,6 +3677,7 @@ dependencies = [ "migration", "sea-orm", "serde", + "serde_json", "snafu", "static-serve", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 9478f71..7c1c3b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ migration = { path = "migration" } sea-orm = { version = "1.1", features = [ "runtime-tokio", "debug-print", "sqlx-sqlite"] } # Serialization/deserialization, for example in path extractors serde = { version = "1.0.219", features = ["derive", "rc"] } +# (De)serialization for operations log +serde_json = { version = "1" } # Error declaration/context snafu = "0.8.8" # Serve static assets directly from the binary @@ -66,3 +68,6 @@ toml = { version = "0.9.5", features = ["preserve_order"] } uucore = { version = "0.1.0", features = ["fsext"] } # Finding XDG standard directories (such as ~/.config/torrentmanager/config.toml) xdg = "3.0.0" + +[dev-dependencies] +async-tempfile = "0.7" \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 6921cc1..c6c095c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -85,6 +85,9 @@ pub struct AppConfig { #[serde(default = "AppConfig::default_sqlite_path")] pub sqlite_path: Utf8PathBuf, + + #[serde(default = "AppConfig::default_log_path")] + pub log_path: Utf8PathBuf, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -119,6 +122,11 @@ impl AppConfig { Self::config_dir().join("database.sqlite") } + pub fn default_log_path() -> Utf8PathBuf { + // At this point the directory has already been successfully created + Self::config_dir().join("operations.log") + } + pub async fn load_from_xdg() -> Result { let config_dir = Self::config_dir(); create_dir_all(&config_dir) diff --git a/src/database/category.rs b/src/database/category.rs index a38eccd..53dc22c 100644 --- a/src/database/category.rs +++ b/src/database/category.rs @@ -1,11 +1,14 @@ use camino::Utf8PathBuf; +use chrono::Utc; use sea_orm::entity::prelude::*; use sea_orm::*; use snafu::prelude::*; +use crate::database::operation::*; use crate::extractors::user::User; use crate::routes::category::CategoryForm; use crate::state::AppState; +use crate::state::logger::LoggerError; /// A category to store associated files. /// @@ -43,6 +46,8 @@ pub enum CategoryError { DB { source: sea_orm::DbErr }, #[snafu(display("The category (ID: {id}) does not exist"))] NotFound { id: i32 }, + #[snafu(display("Failed to save the operation log"))] + Logger { source: LoggerError }, } #[derive(Clone, Debug)] @@ -88,7 +93,11 @@ impl CategoryOperator { /// /// - name or path is already taken (they should be unique) /// - path parent directory does not exist (to avoid completely wrong paths) - pub async fn create(&self, f: &CategoryForm) -> Result { + pub async fn create( + &self, + f: &CategoryForm, + user: Option, + ) -> Result { let dir = Utf8PathBuf::from(&f.path); let parent = dir.parent().unwrap(); @@ -121,6 +130,27 @@ impl CategoryOperator { .await .context(DBSnafu)?; - Ok(model.try_into_model().unwrap()) + // Should not fail + let model = model.try_into_model().unwrap(); + + let operation_log = OperationLog { + user, + date: Utc::now(), + table: Table::Category, + operation: OperationType::Create, + operation_id: OperationId { + object_id: model.id.to_owned(), + name: f.name.to_string(), + }, + operation_form: Operation::Category(f.clone()), + }; + + self.state + .logger + .write(operation_log) + .await + .context(LoggerSnafu)?; + + Ok(model) } } diff --git a/src/database/mod.rs b/src/database/mod.rs index d697d7b..9f25b45 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,2 +1,3 @@ // sea_orm example: https://github.com/SeaQL/sea-orm/blob/master/examples/axum_example/ pub mod category; +pub mod operation; diff --git a/src/database/operation.rs b/src/database/operation.rs index ad119a7..96ee664 100644 --- a/src/database/operation.rs +++ b/src/database/operation.rs @@ -1,6 +1,12 @@ +use chrono::{DateTime, Utc}; +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use crate::extractors::user::User; +use crate::routes::category::CategoryForm; /// Type of operation applied to the database. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Display, Serialize, Deserialize)] pub enum OperationType { Create, Update, @@ -9,11 +15,11 @@ pub enum OperationType { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct OperationId { - pub object_id: i64, + pub object_id: i32, pub name: String, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Display, Serialize, Deserialize)] pub enum Table { Category, } @@ -22,17 +28,24 @@ pub enum Table { /// /// Will be saved as an [OperationLog]. #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] pub enum Operation { Category(CategoryForm), } +impl std::fmt::Display for Operation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", &serde_json::to_string(self).unwrap()) + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct OperationLog { - user: Option, - date: DateTime, - table: Table, - operation: OperationType, - operation_id: OperationId, + pub user: Option, + pub date: DateTime, + pub table: Table, + pub operation: OperationType, + pub operation_id: OperationId, // Raw operation parameters - operation_form: CategoryForm, + pub operation_form: Operation, } diff --git a/src/extractors/user.rs b/src/extractors/user.rs index b406d37..c062712 100644 --- a/src/extractors/user.rs +++ b/src/extractors/user.rs @@ -3,12 +3,14 @@ use axum::{ http::{StatusCode, request::Parts}, }; use derive_more::Display; +use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Display)] /// A logged-in user, as expressed by the Remote-User header. /// /// Cannot be produced outside of header extraction. -pub struct User(String); +#[derive(Clone, Debug, Display, Deserialize, Serialize)] +#[serde(transparent)] +pub struct User(pub String); impl OptionalFromRequestParts for User where diff --git a/src/lib.rs b/src/lib.rs index e591b13..cef7749 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,7 @@ pub fn router(state: state::AppState) -> Router { .route("/categories", get(routes::category::index)) .route("/categories", post(routes::category::create)) .route("/categories/new", get(routes::category::new)) - .route("/categories/{id}/delete", get(routes::category::delete)) + .route("/logs", get(routes::logs::index)) // Register static assets routes .nest("/assets", static_router()) // Insert request timing diff --git a/src/routes/category.rs b/src/routes/category.rs index 1a2e29c..6fb87b9 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -3,8 +3,7 @@ use askama_web::WebTemplate; use axum::Form; use axum::extract::{Path, State}; use axum::response::IntoResponse; -// use sea_orm::entity::*; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use snafu::prelude::*; use crate::database::category::CategoryError; @@ -12,7 +11,7 @@ use crate::database::{category, category::CategoryOperator}; use crate::extractors::user::User; use crate::state::{AppState, AppStateContext, error::*}; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct CategoryForm { pub name: String, pub path: String, @@ -105,7 +104,7 @@ pub async fn create( let app_state_context = app_state.context().await?; let categories = CategoryOperator::new(app_state.clone(), user.clone()); - let created = categories.create(&form).await; + let created = categories.create(&form, user.clone()).await; match created { Ok(created) => Ok(CategoriesTemplate { diff --git a/src/routes/logs.rs b/src/routes/logs.rs new file mode 100644 index 0000000..fea6051 --- /dev/null +++ b/src/routes/logs.rs @@ -0,0 +1,30 @@ +use askama::Template; +use askama_web::WebTemplate; +use axum::extract::State; +use snafu::prelude::*; + +use crate::database::operation::OperationLog; +use crate::extractors::user::User; +use crate::state::{AppState, AppStateContext, error::*}; + +#[derive(Template, WebTemplate)] +#[template(path = "logs.html")] +pub struct LogTemplate { + pub state: AppStateContext, + pub logs: Vec, + pub user: Option, +} + +pub async fn index( + State(app_state): State, + user: Option, +) -> Result { + let app_state_context = app_state.context().await?; + let logs = app_state.logger.read().await.context(LoggerSnafu)?; + + Ok(LogTemplate { + state: app_state_context, + logs, + user, + }) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index d21c93b..75462de 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,3 +1,4 @@ pub mod category; pub mod index; +pub mod logs; pub mod progress; diff --git a/src/state/error.rs b/src/state/error.rs index c87a5a8..f2efa0f 100644 --- a/src/state/error.rs +++ b/src/state/error.rs @@ -7,6 +7,7 @@ use snafu::prelude::*; use crate::database::category::CategoryError; use crate::state::free_space::FreeSpaceError; +use crate::state::logger::LoggerError; #[derive(Debug, Snafu)] #[snafu(visibility(pub))] @@ -19,6 +20,8 @@ pub enum AppStateError { FreeSpace { source: FreeSpaceError }, #[snafu(display("SQLite error"))] Sqlite { source: sea_orm::error::DbErr }, + #[snafu(display("Logger error"))] + Logger { source: LoggerError }, #[snafu(display("An other error occurred"))] Other { source: Box, diff --git a/src/state/logger.rs b/src/state/logger.rs new file mode 100644 index 0000000..a0b78b4 --- /dev/null +++ b/src/state/logger.rs @@ -0,0 +1,223 @@ +use camino::Utf8PathBuf; +use snafu::prelude::*; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::sync::RwLock; + +use std::io::SeekFrom; +use std::sync::Arc; + +use crate::database::operation::OperationLog; + +#[derive(Debug, Snafu)] +#[snafu(visibility(pub))] +pub enum LoggerError { + /// Failed to create the log file, which did not previously exist. + #[snafu(display("Failed to create the operations log {path}"))] + Create { + path: Utf8PathBuf, + source: std::io::Error, + }, + /// Failed to read the log file + #[snafu(display("Failed to read the operations log {path}"))] + Read { + path: Utf8PathBuf, + source: std::io::Error, + }, + /// Failed to append a line to the log file + #[snafu(display("Failed to append to the operations log {path}"))] + Append { + path: Utf8PathBuf, + source: std::io::Error, + }, + #[snafu(display("Failed to parse JSON from operations log {path}"))] + Parse { + path: Utf8PathBuf, + source: serde_json::Error, + }, + #[snafu(display("Other IO error with operaitons log {path}"))] + IO { + path: Utf8PathBuf, + source: std::io::Error, + }, +} + +#[derive(Clone, Debug)] +pub struct Logger { + /// Log file storage + pub path: Utf8PathBuf, + + /// Handle to the log file in append-mode + pub handle: Arc>, +} + +impl Logger { + pub async fn new(path: Utf8PathBuf) -> Result { + let mut handle = OpenOptions::new(); + let mut handle = handle.append(true).read(true); + let handle = if !tokio::fs::try_exists(&path).await.context(ReadSnafu { + path: path.to_path_buf(), + })? { + handle = handle.create(true); + handle.open(&path).await.context(CreateSnafu { + path: path.to_path_buf(), + })? + } else { + handle.open(&path).await.context(ReadSnafu { + path: path.to_path_buf(), + })? + }; + + Ok(Self { + path: path.to_path_buf(), + handle: Arc::new(RwLock::new(handle)), + }) + } + + pub async fn write(&self, operation: OperationLog) -> Result<(), LoggerError> { + // This should never fail + let operation = serde_json::to_string(&operation).unwrap(); + + let mut handle = self.handle.write().await; + handle + .write(operation.as_bytes()) + .await + .context(AppendSnafu { + path: self.path.to_path_buf(), + })?; + handle.write(b"\n").await.context(AppendSnafu { + path: self.path.to_path_buf(), + })?; + + handle.flush().await.context(IOSnafu { + path: self.path.to_path_buf(), + })?; + + Ok(()) + } + + pub async fn read(&self) -> Result, LoggerError> { + // When in append mode, the cursor may be set to the end of file + // so start again from the beginning + let mut s = String::new(); + { + let mut handle = self.handle.write().await; + handle.seek(SeekFrom::Start(0)).await.context(IOSnafu { + path: self.path.to_path_buf(), + })?; + + handle.read_to_string(&mut s).await.context(ReadSnafu { + path: self.path.to_path_buf(), + })?; + } + + // Now that we have dropped the RwLock, parse the results + s.lines() + .map(|entry| { + serde_json::from_str(entry).context(ParseSnafu { + path: self.path.to_path_buf(), + }) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use async_tempfile::TempFile; + use camino::Utf8PathBuf; + use chrono::Utc; + use tokio::task::JoinSet; + + use super::*; + use crate::database::operation::*; + use crate::extractors::user::User; + use crate::routes::category::CategoryForm; + + #[tokio::test] + async fn many_writers() { + let mut set = JoinSet::new(); + let tmpfile = + Utf8PathBuf::from_path_buf(TempFile::new().await.unwrap().file_path().to_path_buf()) + .unwrap(); + let logger = Logger::new(tmpfile.clone()).await.unwrap(); + + let operation_log = OperationLog { + user: Some(User("foo".to_string())), + date: Utc::now(), + table: Table::Category, + operation: OperationType::Create, + operation_id: OperationId { + name: "object".to_string(), + object_id: 1, + }, + operation_form: Operation::Category(CategoryForm { + name: "object".to_string(), + path: "path".to_string(), + }), + }; + + for _i in 0..100 { + let logger = logger.clone(); + let operation_log = operation_log.clone(); + set.spawn(async move { logger.write(operation_log).await }); + } + + for task in set.join_all().await { + assert!(task.is_ok()); + } + + let s = tokio::fs::read_to_string(&tmpfile).await.unwrap(); + println!("{s}"); + + let logs = logger.read().await.unwrap(); + assert_eq!(logs.len(), 100); + } + + #[tokio::test] + async fn mixed_readers_writers() { + let mut set = JoinSet::new(); + let tmpfile = + Utf8PathBuf::from_path_buf(TempFile::new().await.unwrap().file_path().to_path_buf()) + .unwrap(); + let logger = Logger::new(tmpfile.clone()).await.unwrap(); + + let operation_log = OperationLog { + user: Some(User("foo".to_string())), + date: Utc::now(), + table: Table::Category, + operation: OperationType::Create, + operation_id: OperationId { + name: "object".to_string(), + object_id: 1, + }, + operation_form: Operation::Category(CategoryForm { + name: "object".to_string(), + path: "path".to_string(), + }), + }; + + for i in 0..200 { + let logger = logger.clone(); + if i % 2 == 0 { + let operation_log = operation_log.clone(); + set.spawn(async move { logger.write(operation_log).await }); + } else { + set.spawn(async move { + let _ = logger.read().await?; + Ok(()) + }); + } + } + + for task in set.join_all().await { + assert!(task.is_ok()); + } + + let s = tokio::fs::read_to_string(&tmpfile).await.unwrap(); + println!("{s}"); + + let logs = logger.read().await.unwrap(); + assert_eq!(logs.len(), 100); + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs index 5bd3f70..fe11496 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -8,9 +8,11 @@ use crate::config::AppConfig; pub mod error; pub mod free_space; +pub mod logger; use error::*; use free_space::FreeSpace; +use logger::Logger; /// Global application state. /// @@ -24,6 +26,9 @@ pub struct AppState { /// Sqlite database pub database: DatabaseConnection, + /// Append-only log for operations + pub logger: Logger, + // TODO: multiple torrent backends pub torrent_client: QBittorrentClient, } @@ -64,15 +69,19 @@ impl AppState { .context(InitAPISnafu)?; let sqlite_path = config.sqlite_path.clone(); - // TODO: dehardcode let database = Database::connect(format!("sqlite://{}?mode=rwc", &sqlite_path)) .await .context(SqliteSnafu)?; Migrator::up(&database, None).await.unwrap(); + let logger = Logger::new(config.log_path.clone()) + .await + .context(LoggerSnafu)?; + Ok(Self { config, database, + logger, torrent_client, }) } diff --git a/templates/logs.html b/templates/logs.html new file mode 100755 index 0000000..b2102f9 --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block main %} +

Logs

+ +{% for log in logs %} +
+ +{{ log.date }} {% if let Some(log_user) = log.user %}{{ log_user }}{% endif %}{{ log.operation }} on table {{ log.table }}: {{ log.operation_id.name }} ({{ log.operation_id.object_id }}) + +{{ log.operation_form }} +
+{% endfor %} +{% endblock %}