changeset 68:2a7b358c2710

Deploy settings page (no admin stuff yet)
author Lewin Bormann <lbo@spheniscida.de>
date Sun, 31 Jul 2022 21:17:53 -0700
parents 8b0e474ba90a
children 3d4401e111d8
files Cargo.toml assets/index.html.hbs assets/index_dashboard.html.hbs assets/settings.html.hbs assets/static/style.css src/configdb.rs src/main.rs
diffstat 7 files changed, 231 insertions(+), 43 deletions(-) [+]
line wrap: on
line diff
--- a/Cargo.toml	Tue Jul 26 08:40:12 2022 -0700
+++ b/Cargo.toml	Sun Jul 31 21:17:53 2022 -0700
@@ -10,15 +10,17 @@
 either = "1.7.0"
 env_logger = "0.9.0"
 log = "0.4.17"
-rocket = { version = "0.5.0-rc.2", features = ["secrets", "json"] }
+rand = "0.8"
 sqlx = { version = "0.5", features = ["macros"] }
-rocket_db_pools = { version = "0.1.0-rc.2", features = [ "sqlx_macros"] }
-rocket_dyn_templates = { version = "0.1.0-rc.2", features = ["handlebars"] }
 sha256 = "1.0.3"
 time = { version = "0.3.11", features = ["serde", "serde-well-known"] }
 tokio = { version = "1.19.2", features = ["fs"] }
 maxminddb = "0.23.0"
 
+rocket = { version = "0.5.0-rc.2", features = ["secrets", "json"] }
+rocket_db_pools = { version = "0.1.0-rc.2", features = [ "sqlx_macros"] }
+rocket_dyn_templates = { version = "0.1.0-rc.2", features = ["handlebars"] }
+
 [features]
 default = ["sqlite"]
 sqlite = ["rocket_db_pools/sqlx_sqlite"]
--- a/assets/index.html.hbs	Tue Jul 26 08:40:12 2022 -0700
+++ b/assets/index.html.hbs	Sun Jul 31 21:17:53 2022 -0700
@@ -9,40 +9,6 @@
         <link rel="stylesheet" href="static/style.css" type="text/css" />
         <link rel="icon" type="image/png" href="static/favicon.ico">
 
-        <script src="static/chart.min.js" type="application/javascript"></script>
-
-        <script type="application/javascript">
-            let from = "{{ date.today }}";
-            let duration = {{ date.duration }}; // in days
-
-            function formatDate(d) {
-                let m = d.getUTCMonth()+1; m = m < 10 ? "0"+m : m;
-                let day = d.getUTCDate(); day = day < 10 ? "0"+day : day;
-
-                return `${d.getUTCFullYear()}-${m}-${day}`;
-            }
-            function adjustURLDateParams(dateOffsetDays, durationMultiplier) {
-                let usp = new URLSearchParams(window.location.search);
-                let from_date = new Date(usp.get("from") || from);
-                console.log("from date", from_date, " ", dateOffsetDays);
-                let gduration = +usp.get("duration") || duration;
-
-                let new_from_date = formatDate(new Date(from_date.getTime() + dateOffsetDays * 86400 * 1000));
-                console.log(from_date, new_from_date);
-                let new_duration = gduration * durationMultiplier;
-
-                usp.set("from", new_from_date);
-                usp.set("duration", new_duration.toFixed());
-                window.location.search = usp.toString();
-            };
-            function toggleBots(checkboxid) {
-                let box = document.getElementById(checkboxid);
-                let usp = new URLSearchParams(window.location.search);
-                usp.set("includebots", box.checked);
-                window.location.search = usp.toString();
-            };
-        </script>
-
     </head>
     <body>
 
--- a/assets/index_dashboard.html.hbs	Tue Jul 26 08:40:12 2022 -0700
+++ b/assets/index_dashboard.html.hbs	Sun Jul 31 21:17:53 2022 -0700
@@ -57,6 +57,7 @@
         <form id="logout" action="logout" method="POST">
             <input type="submit" value="Log out" />
         </form>
+        <div class="usersettings"><a href="{{ basepath }}settings">Settings</a></div>
         <div class="useraction"><b>{{username}}</b>.</div>
         <div class="userdomain">{{#if domain}}Domain: <b>{{domain}}</b>{{else}}<i>(all domains)</i>{{/if}}</div>
     </div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/assets/settings.html.hbs	Sun Jul 31 21:17:53 2022 -0700
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <title>Analyrics - Settings</title>
+
+        <meta name="robots" content="noindex">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+
+        <link rel="stylesheet" href="static/style.css" type="text/css" />
+        <link rel="icon" type="image/png" href="static/favicon.ico">
+
+    </head>
+    <body>
+
+    <!-- Header -->
+    <div id="header">
+        <span id="logo">AnaLyrics</span>
+        <form id="logout" action="logout" method="POST">
+            <input type="submit" value="Log out" />
+        </form>
+        <a class="useraction" href="{{ basepath }}">Dashboard</a>
+    </div>
+    {{#if flash}}<div id="flash"><span id="flashtext">{{flash}}<span></div>{{/if}}
+    {{#if error}}<div id="error"><span id="errortext">{{error}}</span></div>{{/if}}
+
+    <h1>Welcome, {{user}}!</h1>
+    
+    <h2>Password</h2>
+    <form id="pwform" action="{{thispath}}" method="POST">
+        <label for="oldpassword">Old password:</label>
+        <input type="password" name="oldpassword" />
+        <label for="newpassword">New password:</label>
+        <input type="password" name="newpassword" />
+        <input type="submit" value="Save" />
+    </form>
+
+    <h2>Time zone</h2>
+    <form id="tzform" action="{{thispath}}" method="POST">
+        <label>Your time zone:</label>
+        <input type="number" min="-12" max="12" name="tz_offset" value="{{tz_offset}}" />
+        <input type="submit" value="Save" />
+    </form>
+
+    </body>
+</html>
--- a/assets/static/style.css	Tue Jul 26 08:40:12 2022 -0700
+++ b/assets/static/style.css	Sun Jul 31 21:17:53 2022 -0700
@@ -1,8 +1,9 @@
 #logo { color: #22bb22; font-size: 20pt; font-style: bold; }
 #header { color: #bb2222; }
-.useraction { float: right;  padding: 3pt; }
-.userdomain { float: right;  padding: 3pt; }
-#logout { float: right; padding: 3pt; }
+.useraction { float: right;  padding: 5pt; }
+.userdomain { float: right;  padding: 5pt; }
+.usersettings { float: right; padding: 5pt; }
+#logout { float: right; padding: 3pt; vertical-align: middle; }
 #flashtext {  }
 #flash { text-align: center; border-style: solid; border-color: #22aa22; margin-left: 30%; margin-right: 30%; }
 #errortext {  }
@@ -17,6 +18,7 @@
 .fullwidth { width: 95%; }
 .halfwidth { width: 48%; }
 
+/* Dashboard */
 #timebox { display: flex; flex-direction: column; align-items: center; padding: 0.5em; }
 #timenav { }
 #timeindicator { padding: 0.2em; }
@@ -42,3 +44,6 @@
 button { height: 1.8em; border: lightgray solid; text-align: center; vertical-align: middle; }
 input:focus { outline: 0; }
 label { }
+
+/* Settings */
+
--- a/src/configdb.rs	Tue Jul 26 08:40:12 2022 -0700
+++ b/src/configdb.rs	Sun Jul 31 21:17:53 2022 -0700
@@ -3,11 +3,16 @@
 use anyhow::Error;
 use log::{debug, error, info, warn, Level};
 
+use rand::distributions::{Alphanumeric, DistString};
 use rocket::futures::StreamExt;
 use rocket_db_pools::sqlx::{Executor, Row, Sqlite, SqlitePool};
 use rocket_db_pools::{Connection, Database, Pool};
 use sqlx::prelude::FromRow;
 
+fn generate_salt() -> String {
+    Alphanumeric.sample_string(&mut rand::thread_rng(), 16)
+}
+
 // TO DO: use other databases?
 #[derive(Database)]
 #[database("sqlite_main")]
@@ -22,10 +27,10 @@
 pub struct ConfigDBSession<'p, DB: sqlx::Database>(pub &'p mut sqlx::pool::PoolConnection<DB>);
 
 impl<'p> ConfigDBSession<'p, Sqlite> {
-    pub async fn check_user_password<S: AsRef<str>>(
+    pub async fn check_user_password<S0: AsRef<str>, S1: AsRef<str>>(
         &mut self,
-        user: S,
-        password: S,
+        user: S0,
+        password: S1,
     ) -> Result<bool, Error> {
         // TODO: salt passwords.
         let salt: String = match sqlx::query("SELECT salt FROM users WHERE username = ? LIMIT 1;")
@@ -54,4 +59,49 @@
             .await?;
         Ok(UserRecord::from_row(&entry)?)
     }
+
+    pub async fn update_user_password<S0: AsRef<str>, S1: AsRef<str>, S2: AsRef<str>>(
+        &mut self,
+        user: S0,
+        oldpass: S1,
+        newpass: S2,
+    ) -> Result<(), Error> {
+        if !self.check_user_password(user, oldpass).await? {
+            anyhow::bail!("User not authorized (wrong password?)")
+        }
+
+        // Old password is ok.
+        let salt = generate_salt();
+        let newpass_hash = sha256::digest(format!("{}{}", salt, newpass.as_ref()));
+
+        sqlx::query("UPDATE users SET salt = ?, password_hash = ?;")
+            .bind(salt)
+            .bind(newpass_hash)
+            .execute(&mut *self.0)
+            .await?;
+
+        Ok(())
+    }
+
+    pub async fn update_user_tz<S0: AsRef<str>>(
+        &mut self,
+        user: S0,
+        tz_offset: i64,
+    ) -> Result<(), Error> {
+        if sqlx::query("SELECT username FROM users WHERE username = ?")
+            .bind(user.as_ref())
+            .fetch_all(&mut *self.0)
+            .await?
+            .len()
+            != 1
+        {
+            anyhow::bail!("User not found, or duplicate user {}", user.as_ref())
+        }
+        sqlx::query("UPDATE users SET tz_offset = ? WHERE username = ?")
+            .bind(tz_offset)
+            .bind(user.as_ref())
+            .execute(&mut *self.0)
+            .await?;
+        Ok(())
+    }
 }
--- a/src/main.rs	Tue Jul 26 08:40:12 2022 -0700
+++ b/src/main.rs	Sun Jul 31 21:17:53 2022 -0700
@@ -191,6 +191,7 @@
 async fn route_index_loggedin(
     mut conn: Connection<LogsDB>,
     mut config_conn: Connection<ConfigDB>,
+    cc: &rocket::State<CustomConfig>,
     lig: LoggedInGuard,
     flash: Option<FlashMessage<'_>>,
     domain: Option<String>,
@@ -373,6 +374,7 @@
         "index_dashboard",
         context![
         loggedin: true,
+        basepath: &cc.deploy_path,
         domain: domain,
         username: lig.0,
         flash: f,
@@ -501,6 +503,120 @@
     }
 }
 
+#[derive(rocket::FromForm)]
+struct SettingsForm {
+    oldpassword: Option<String>,
+    newpassword: Option<String>,
+    tz_offset: Option<i64>,
+}
+
+#[rocket::post("/settings", data = "<settings>")]
+async fn route_settings_post(
+    mut cdb: Connection<ConfigDB>,
+    cc: &rocket::State<CustomConfig>,
+    lig: LoggedInGuard,
+    settings: rocket::form::Form<SettingsForm>,
+) -> Flash<Redirect> {
+    let mut db = ConfigDBSession(&mut cdb);
+    let SettingsForm {
+        oldpassword,
+        newpassword,
+        tz_offset,
+    } = settings.into_inner();
+    let deploy_path = cc.deploy_path.clone();
+    let settingspath = Path::new(&deploy_path)
+        .join("settings")
+        .to_str()
+        .unwrap_or("/settings")
+        .to_string();
+
+    // Update settings before rendering.
+    if let (Some(newpassword), Some(oldpassword)) = (&newpassword, &oldpassword) {
+        if let Err(e) = db
+            .update_user_password(&lig.0, &oldpassword, &newpassword)
+            .await
+        {
+            return Flash::error(
+                Redirect::to(settingspath),
+                format!("Couldn't update user password: {}", e),
+            );
+        }
+    } else if newpassword.is_some() ^ oldpassword.is_some() {
+        return Flash::error(
+            Redirect::to(settingspath),
+            "Internal error: must supply old and new password".to_string(),
+        );
+    }
+
+    if let Some(tz_offset) = tz_offset {
+        if let Err(e) = db.update_user_tz(&lig.0, tz_offset * 3600).await {
+            return Flash::error(
+                Redirect::to(settingspath),
+                format!("Couldn't update timezone: {}", e),
+            );
+        }
+    }
+
+    Flash::success(Redirect::to(settingspath), format!("Saved successfully!"))
+}
+
+#[rocket::get("/settings", rank = 1)]
+async fn route_settings(
+    mut cdb: Connection<ConfigDB>,
+    flash: Option<FlashMessage<'_>>,
+    cc: &rocket::State<CustomConfig>,
+    lig: LoggedInGuard,
+) -> Template {
+    let mut db = ConfigDBSession(&mut cdb);
+    let user = match db.get_user_details(&lig.0).await {
+        Ok(r) => r,
+        Err(e) => {
+            return Template::render(
+                "settings",
+                context![ basepath: &cc.deploy_path, error: format!("Error getting user details: {}", e) ],
+            )
+        }
+    };
+
+    let error = if let Some(flash) = &flash {
+        if flash.kind() == "error" {
+            Some(flash.message())
+        } else {
+            None
+        }
+    } else {
+        None
+    };
+    let success = if let Some(flash) = &flash {
+        if flash.kind() == "success" {
+            Some(flash.message())
+        } else {
+            None
+        }
+    } else {
+        None
+    };
+
+    Template::render(
+        "settings",
+        context![
+            basepath: &cc.deploy_path,
+            thispath: format!("{}{}", &cc.deploy_path, "settings"),
+            user: &user.name,
+            error: error,
+            flash: success,
+
+            tz_offset: format!("{:}", user.tz_offset / 3600),
+
+        ],
+    )
+}
+
+#[rocket::get("/settings", rank = 2)]
+async fn route_settings_loggedout(cc: &rocket::State<CustomConfig>) -> Redirect {
+    Redirect::to(cc.deploy_path.clone())
+}
+
 #[derive(rocket::serde::Deserialize)]
 #[serde(crate = "rocket::serde")]
 struct CustomConfig {
@@ -549,6 +665,9 @@
             rocket::routes![
                 route_index_loggedin,
                 route_index_loggedout,
+                route_settings,
+                route_settings_post,
+                route_settings_loggedout,
                 route_logout,
                 route_login_form,
                 route_login_post,