From c5724f40b7ebdfb7be0956edc4409c8210f558ea Mon Sep 17 00:00:00 2001 From: Keagan McClelland Date: Mon, 13 Sep 2021 14:59:19 -0600 Subject: [PATCH] Implements a notification system for EmbassyOS --- appmgr/migrations/20210629193146_Init.sql | 13 +- appmgr/src/error.rs | 2 + appmgr/src/id.rs | 5 + appmgr/src/lib.rs | 1 + appmgr/src/notifications.rs | 265 ++++++++++++++++++++++ appmgr/src/s9pk/manifest.rs | 5 + 6 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 appmgr/src/notifications.rs diff --git a/appmgr/migrations/20210629193146_Init.sql b/appmgr/migrations/20210629193146_Init.sql index 4fa90754b..c0d2b2abc 100644 --- a/appmgr/migrations/20210629193146_Init.sql +++ b/appmgr/migrations/20210629193146_Init.sql @@ -36,4 +36,15 @@ CREATE TABLE IF NOT EXISTS certificates lookup_string TEXT UNIQUE, created_at TEXT, updated_at TEXT -); \ No newline at end of file +); +CREATE TABLE IF NOT EXISTS notifications +( + id INTEGER PRIMARY KEY, + package_id TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + code INTEGER NOT NULL, + level TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + data TEXT +) \ No newline at end of file diff --git a/appmgr/src/error.rs b/appmgr/src/error.rs index 93b410147..252834466 100644 --- a/appmgr/src/error.rs +++ b/appmgr/src/error.rs @@ -57,6 +57,7 @@ pub enum ErrorKind { OpenSsl = 49, PasswordHashGeneration = 50, RecoveryMode = 51, + ParseDbField = 52, } impl ErrorKind { pub fn as_str(&self) -> &'static str { @@ -113,6 +114,7 @@ impl ErrorKind { OpenSsl => "OpenSSL Internal Error", PasswordHashGeneration => "Password Hash Generation Error", RecoveryMode => "Embassy is in Recovery Mode", + ParseDbField => "Database Field Parse Error", } } } diff --git a/appmgr/src/id.rs b/appmgr/src/id.rs index 128578b6d..351226dfb 100644 --- a/appmgr/src/id.rs +++ b/appmgr/src/id.rs @@ -89,6 +89,11 @@ impl<'a> Id<&'a str> { Id(self.0.to_owned()) } } +impl From for String { + fn from(value: Id) -> Self { + value.0 + } +} impl> std::ops::Deref for Id { type Target = S; fn deref(&self) -> &Self::Target { diff --git a/appmgr/src/lib.rs b/appmgr/src/lib.rs index 4d7f4e9ec..737c5009b 100644 --- a/appmgr/src/lib.rs +++ b/appmgr/src/lib.rs @@ -26,6 +26,7 @@ pub mod manager; pub mod middleware; pub mod migration; pub mod net; +pub mod notifications; pub mod properties; pub mod recovery; pub mod s9pk; diff --git a/appmgr/src/notifications.rs b/appmgr/src/notifications.rs new file mode 100644 index 000000000..a6b8d69d6 --- /dev/null +++ b/appmgr/src/notifications.rs @@ -0,0 +1,265 @@ +use std::collections::HashMap; +use std::fmt; +use std::str::FromStr; + +use anyhow::anyhow; +use chrono::{DateTime, Utc}; +use patch_db::{LockType, PatchDb}; +use rpc_toolkit::command; +use sqlx::SqlitePool; + +use crate::context::RpcContext; +use crate::s9pk::manifest::PackageId; +use crate::util::{display_none, display_serializable}; +use crate::{Error, ErrorKind}; + +#[command(subcommands(list, delete, delete_before))] +pub async fn notification(#[context] _ctx: RpcContext) -> Result<(), Error> { + Ok(()) +} + +#[command(display(display_serializable))] +pub async fn list( + #[context] ctx: RpcContext, + #[arg] before: Option, + #[arg] limit: Option, +) -> Result, Error> { + let limit = limit.unwrap_or(40); + let mut handle = ctx.db.handle(); + match before { + None => { + let model = crate::db::DatabaseModel::new() + .server_info() + .unread_notification_count(); + model.lock(&mut handle, patch_db::LockType::Write).await; + let records = sqlx::query!( + "SELECT id, package_id, created_at, code, level, title, message, data FROM notifications ORDER BY id DESC LIMIT ?", + limit + ).fetch_all(&ctx.secret_store).await?; + let notifs = records + .into_iter() + .map(|r| { + Ok(Notification { + id: r.id as u32, + package_id: r.package_id.and_then(|p| p.parse().ok()), + created_at: DateTime::from_utc(r.created_at, Utc), + code: r.code as u32, + level: match r.level.parse::() { + Ok(a) => a, + Err(e) => return Err(e.into()), + }, + title: r.title, + message: r.message, + data: match r.data { + None => serde_json::Value::Null, + Some(v) => match v.parse::() { + Ok(a) => a, + Err(e) => { + return Err(Error::new( + anyhow!("Invalid Notification Data: {}", e), + ErrorKind::ParseDbField, + )) + } + }, + }, + }) + }) + .collect::, Error>>()?; + // set notification count to zero + model.put(&mut handle, &0).await?; + Ok(notifs) + } + Some(before) => { + let records = sqlx::query!( + "SELECT id, package_id, created_at, code, level, title, message, data FROM notifications WHERE id < ? ORDER BY id DESC LIMIT ?", + before, + limit + ).fetch_all(&ctx.secret_store).await?; + records + .into_iter() + .map(|r| { + Ok(Notification { + id: r.id as u32, + package_id: r.package_id.and_then(|p| p.parse().ok()), + created_at: DateTime::from_utc(r.created_at, Utc), + code: r.code as u32, + level: match r.level.parse::() { + Ok(a) => a, + Err(e) => return Err(e.into()), + }, + title: r.title, + message: r.message, + data: match r.data { + None => serde_json::Value::Null, + Some(v) => match v.parse::() { + Ok(a) => a, + Err(e) => { + return Err(Error::new( + anyhow!("Invalid Notification Data: {}", e), + ErrorKind::ParseDbField, + )) + } + }, + }, + }) + }) + .collect::, Error>>() + } + } +} + +#[command(display(display_none))] +pub async fn delete(#[context] ctx: RpcContext, #[arg] id: u32) -> Result<(), Error> { + sqlx::query!("DELETE FROM notifications WHERE id = ?", id) + .execute(&ctx.secret_store) + .await?; + Ok(()) +} + +#[command(display(display_none))] +pub async fn delete_before(#[context] ctx: RpcContext, #[arg] before: u32) -> Result<(), Error> { + sqlx::query!("DELETE FROM notifications WHERE id < ?", before) + .execute(&ctx.secret_store) + .await?; + Ok(()) +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub enum NotificationLevel { + Success, + Info, + Warning, + Error, +} +impl fmt::Display for NotificationLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + NotificationLevel::Success => write!(f, "success"), + NotificationLevel::Info => write!(f, "info"), + NotificationLevel::Warning => write!(f, "warning"), + NotificationLevel::Error => write!(f, "error"), + } + } +} +pub struct InvalidNotificationLevel; +impl From for crate::Error { + fn from(_val: InvalidNotificationLevel) -> Self { + Error::new( + anyhow!("Invalid Notification Level"), + ErrorKind::ParseDbField, + ) + } +} +impl FromStr for NotificationLevel { + type Err = InvalidNotificationLevel; + fn from_str(s: &str) -> Result { + match s { + s if s == "success" => Ok(NotificationLevel::Success), + s if s == "info" => Ok(NotificationLevel::Info), + s if s == "warning" => Ok(NotificationLevel::Warning), + s if s == "error" => Ok(NotificationLevel::Error), + _ => Err(InvalidNotificationLevel), + } + } +} +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Notification { + id: u32, + package_id: Option, // TODO change for package id newtype + created_at: DateTime, + code: u32, + level: NotificationLevel, + title: String, + message: String, + data: serde_json::Value, +} + +pub enum NotificationSubtype { + General, + BackupReport { + server_attempted: bool, + server_error: Option, + packages: HashMap>, + }, +} +impl NotificationSubtype { + fn to_json(&self) -> serde_json::Value { + match &self { + &NotificationSubtype::General => serde_json::Value::Null, + &NotificationSubtype::BackupReport { + server_attempted, + server_error, + packages, + } => { + let mut pkgs_map = serde_json::Map::new(); + for (k, v) in packages.iter() { + pkgs_map.insert( + k.clone(), + match v { + None => serde_json::json!({ "error": serde_json::Value::Null }), + Some(x) => serde_json::json!({ "error": x }), + }, + ); + } + serde_json::json!({ + "server": { + "attempted": server_attempted, + "error": server_error, + }, + "packages": serde_json::Value::Object(pkgs_map) + }) + } + } + } + fn code(&self) -> u32 { + match &self { + &Self::General => 0, + &Self::BackupReport { + server_attempted: _, + server_error: _, + packages: _, + } => 1, + } + } +} + +pub async fn notify( + sqlite: &SqlitePool, + patchdb: &PatchDb, + package_id: Option, + level: NotificationLevel, + title: String, + message: String, + subtype: NotificationSubtype, +) -> Result<(), Error> { + let mut handle = patchdb.handle(); + let mut count = crate::db::DatabaseModel::new() + .server_info() + .unread_notification_count() + .get_mut(&mut handle) + .await?; + let sql_package_id = package_id.map::(|p| p.into()); + let sql_code = subtype.code(); + let sql_level = format!("{}", level); + let sql_data = format!("{}", subtype.to_json()); + sqlx::query!( + "INSERT INTO notifications (package_id, code, level, title, message, data) VALUES (?, ?, ?, ?, ?, ?)", + sql_package_id, + sql_code, + sql_level, + title, + message, + sql_data + ).execute(sqlite).await?; + *count += 1; + count.save(&mut handle).await?; + Ok(()) +} + +#[test] +fn serialization() { + println!( + "{}", + serde_json::json!({ "test": "abcdefg", "num": 32, "nested": { "inner": null, "xyz": [0,2,4]}}) + ) +} diff --git a/appmgr/src/s9pk/manifest.rs b/appmgr/src/s9pk/manifest.rs index d30c101cf..66e51a97e 100644 --- a/appmgr/src/s9pk/manifest.rs +++ b/appmgr/src/s9pk/manifest.rs @@ -32,6 +32,11 @@ impl FromStr for PackageId { Ok(PackageId(Id::try_from(s.to_owned())?)) } } +impl From for String { + fn from(value: PackageId) -> Self { + value.0.into() + } +} impl> From> for PackageId { fn from(id: Id) -> Self { PackageId(id)