Compare commits

..

5 Commits

Author SHA1 Message Date
06e1474396 Merge pull request 'Defer offset response' (#2) from jude/fix-offset into current
Reviewed-on: #2
2023-11-06 17:35:27 +00:00
jude
adb9c728f4 Defer offset response 2023-11-06 16:57:50 +00:00
jude
fc02eaea4a Update styles for notification flash 2023-11-06 16:53:04 +00:00
jude
91f87302fb Build dashboard 2023-10-30 17:55:33 +00:00
jude
97f186dc33 Add metrics
Change dashboards to load an index.html file compiled otherwise
2023-10-29 18:00:45 +00:00
15 changed files with 616 additions and 505 deletions

2
.gitignore vendored
View File

@ -3,3 +3,5 @@
/venv /venv
.cargo .cargo
/.idea /.idea
web/static/index.html
web/static/assets

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "reminder-dashboard"]
path = reminder-dashboard
url = gitea@gitea.jellypro.xyz:jude/reminder-dashboard

879
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "reminder-rs" name = "reminder-rs"
version = "1.6.50" version = "1.6.48"
authors = ["Jude Southworth <judesouthworth@pm.me>"] authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021" edition = "2021"
license = "AGPL-3.0 only" license = "AGPL-3.0 only"
@ -43,6 +43,7 @@ assets = [
["conf/default.env", "etc/reminder-rs/config.env", "600"], ["conf/default.env", "etc/reminder-rs/config.env", "600"],
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"], ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
["web/static/**/*", "lib/reminder-rs/static", "644"], ["web/static/**/*", "lib/reminder-rs/static", "644"],
["reminder-dashboard/dist/static/**/*", "lib/reminder-rs/static", "644"],
["web/templates/**/*", "lib/reminder-rs/templates", "644"], ["web/templates/**/*", "lib/reminder-rs/templates", "644"],
["healthcheck", "lib/reminder-rs/healthcheck", "755"], ["healthcheck", "lib/reminder-rs/healthcheck", "755"],
["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"], ["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"],

1
reminder-dashboard Submodule

@ -0,0 +1 @@
Subproject commit 8ba7a39ce532907b845c54dd3b54bf2a4c7a04e9

View File

@ -1,6 +1,6 @@
[package] [package]
name = "reminder_web" name = "reminder_web"
version = "0.1.4" version = "0.1.3"
authors = ["jellywx <judesouthworth@pm.me>"] authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018" edition = "2018"
@ -19,3 +19,4 @@ lazy_static = "1.4.0"
rand = "0.8" rand = "0.8"
base64 = "0.13" base64 = "0.13"
csv = "1.2" csv = "1.2"
prometheus = "0.13.3"

View File

@ -33,9 +33,11 @@ impl<'r> FromRequest<'r> for Transaction<'r> {
match request.guard::<&State<Pool<Database>>>().await { match request.guard::<&State<Pool<Database>>>().await {
Outcome::Success(pool) => match pool.begin().await { Outcome::Success(pool) => match pool.begin().await {
Ok(transaction) => Outcome::Success(Transaction(transaction)), Ok(transaction) => Outcome::Success(Transaction(transaction)),
Err(e) => Outcome::Error((Status::InternalServerError, TransactionError::Error(e))), Err(e) => {
Outcome::Failure((Status::InternalServerError, TransactionError::Error(e)))
}
}, },
Outcome::Error(e) => Outcome::Error((e.0, TransactionError::Missing)), Outcome::Failure(e) => Outcome::Failure((e.0, TransactionError::Missing)),
Outcome::Forward(f) => Outcome::Forward(f), Outcome::Forward(f) => Outcome::Forward(f),
} }
} }

View File

@ -6,6 +6,7 @@ mod consts;
mod macros; mod macros;
mod catchers; mod catchers;
mod guards; mod guards;
mod metrics;
mod routes; mod routes;
use std::{env, path::Path}; use std::{env, path::Path};
@ -25,7 +26,10 @@ use serenity::{
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}; use crate::{
consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES},
metrics::{init_metrics, MetricProducer},
};
type Database = MySql; type Database = MySql;
@ -64,7 +68,10 @@ pub async fn initialize(
let static_path = let static_path =
if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" }; if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" };
init_metrics();
rocket::build() rocket::build()
.attach(MetricProducer)
.attach(Template::fairing()) .attach(Template::fairing())
.register( .register(
"/", "/",
@ -85,12 +92,13 @@ pub async fn initialize(
.mount( .mount(
"/", "/",
routes![ routes![
routes::index,
routes::cookies, routes::cookies,
routes::index,
routes::metrics::metrics,
routes::privacy, routes::privacy,
routes::terms,
routes::return_to_same_site,
routes::report::report_error, routes::report::report_error,
routes::return_to_same_site,
routes::terms,
], ],
) )
.mount( .mount(

43
web/src/metrics.rs Normal file
View File

@ -0,0 +1,43 @@
use lazy_static::lazy_static;
use prometheus::{IntCounterVec, Opts, Registry};
use rocket::{
fairing::{Fairing, Info, Kind},
Data, Request, Response,
};
lazy_static! {
pub static ref REGISTRY: Registry = Registry::new();
static ref REQUEST_COUNTER: IntCounterVec =
IntCounterVec::new(Opts::new("requests", "Requests"), &["method", "route"]).unwrap();
static ref RESPONSE_COUNTER: IntCounterVec =
IntCounterVec::new(Opts::new("responses", "Responses"), &["status", "route"]).unwrap();
}
pub fn init_metrics() {
REGISTRY.register(Box::new(REQUEST_COUNTER.clone())).unwrap();
}
pub struct MetricProducer;
#[rocket::async_trait]
impl Fairing for MetricProducer {
fn info(&self) -> Info {
Info { name: "Metrics fairing", kind: Kind::Request }
}
async fn on_request(&self, req: &mut Request<'_>, _data: &mut Data<'_>) {
if let Some(route) = req.route() {
REQUEST_COUNTER
.with_label_values(&[req.method().as_str(), &route.uri.to_string()])
.inc();
}
}
async fn on_response<'r>(&self, req: &'r Request<'_>, resp: &mut Response<'r>) {
if let Some(route) = req.route() {
RESPONSE_COUNTER
.with_label_values(&[&resp.status().code.to_string(), &route.uri.to_string()])
.inc();
}
}
}

View File

@ -1 +0,0 @@

View File

@ -1,10 +1,14 @@
use std::collections::HashMap; use std::path::Path;
use chrono::{naive::NaiveDateTime, Utc}; use chrono::{naive::NaiveDateTime, Utc};
use rand::{rngs::OsRng, seq::IteratorRandom}; use rand::{rngs::OsRng, seq::IteratorRandom};
use rocket::{http::CookieJar, response::Redirect, serde::json::json}; use rocket::{
use rocket_dyn_templates::Template; fs::{relative, NamedFile},
use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; http::CookieJar,
response::Redirect,
serde::json::json,
};
use serde::{Deserialize, Deserializer, Serialize};
use serenity::{ use serenity::{
client::Context, client::Context,
http::Http, http::Http,
@ -27,7 +31,6 @@ use crate::{
pub mod api; pub mod api;
pub mod export; pub mod export;
pub mod guild;
type Unset<T> = Option<T>; type Unset<T> = Option<T>;
@ -51,27 +54,12 @@ fn interval_default() -> Unset<Option<u32>> {
None None
} }
#[derive(sqlx::Type)] fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
#[sqlx(transparent)]
struct Attachment(Vec<u8>);
impl<'de> Deserialize<'de> for Attachment {
fn deserialize<D>(deserializer: D) -> Result<Attachment, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
T: Deserialize<'de>,
{ {
let string = String::deserialize(deserializer)?; Ok(Some(Option::deserialize(deserializer)?))
Ok(Attachment(base64::decode(string).map_err(de::Error::custom)?))
}
}
impl Serialize for Attachment {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(&base64::encode(&self.0))
}
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -82,7 +70,7 @@ pub struct ReminderTemplate {
guild_id: u32, guild_id: u32,
#[serde(default = "template_name_default")] #[serde(default = "template_name_default")]
name: String, name: String,
attachment: Option<Attachment>, attachment: Option<Vec<u8>>,
attachment_name: Option<String>, attachment_name: Option<String>,
avatar: Option<String>, avatar: Option<String>,
content: String, content: String,
@ -107,7 +95,7 @@ pub struct ReminderTemplate {
pub struct ReminderTemplateCsv { pub struct ReminderTemplateCsv {
#[serde(default = "template_name_default")] #[serde(default = "template_name_default")]
name: String, name: String,
attachment: Option<Attachment>, attachment: Option<Vec<u8>>,
attachment_name: Option<String>, attachment_name: Option<String>,
avatar: Option<String>, avatar: Option<String>,
content: String, content: String,
@ -142,7 +130,8 @@ pub struct EmbedField {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct Reminder { pub struct Reminder {
attachment: Option<Attachment>, #[serde(with = "base64s")]
attachment: Option<Vec<u8>>,
attachment_name: Option<String>, attachment_name: Option<String>,
avatar: Option<String>, avatar: Option<String>,
#[serde(with = "string")] #[serde(with = "string")]
@ -175,7 +164,8 @@ pub struct Reminder {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct ReminderCsv { pub struct ReminderCsv {
attachment: Option<Attachment>, #[serde(with = "base64s")]
attachment: Option<Vec<u8>>,
attachment_name: Option<String>, attachment_name: Option<String>,
avatar: Option<String>, avatar: Option<String>,
channel: String, channel: String,
@ -208,7 +198,7 @@ pub struct PatchReminder {
uid: String, uid: String,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")] #[serde(deserialize_with = "deserialize_optional_field")]
attachment: Unset<Option<Attachment>>, attachment: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")] #[serde(deserialize_with = "deserialize_optional_field")]
attachment_name: Unset<Option<String>>, attachment_name: Unset<Option<String>>,
@ -304,14 +294,6 @@ pub fn generate_uid() -> String {
.join("") .join("")
} }
fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Some(Option::deserialize(deserializer)?))
}
// https://github.com/serde-rs/json/issues/329#issuecomment-305608405 // https://github.com/serde-rs/json/issues/329#issuecomment-305608405
mod string { mod string {
use std::{fmt::Display, str::FromStr}; use std::{fmt::Display, str::FromStr};
@ -336,6 +318,29 @@ mod string {
} }
} }
mod base64s {
use serde::{de, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(value: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Some(opt) = value {
serializer.collect_str(&base64::encode(opt))
} else {
serializer.serialize_none()
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
where
D: Deserializer<'de>,
{
let string = Option::<String>::deserialize(deserializer)?;
Some(string.map(|b| base64::decode(b).map_err(de::Error::custom))).flatten().transpose()
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DeleteReminder { pub struct DeleteReminder {
uid: String, uid: String,
@ -655,22 +660,26 @@ async fn create_database_channel(
} }
#[get("/")] #[get("/")]
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> {
if cookies.get_private("userid").is_some() { if cookies.get_private("userid").is_some() {
let mut map = HashMap::new(); NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| {
map.insert("version", env!("CARGO_PKG_VERSION")); warn!("Couldn't render dashboard: {:?}", e);
Ok(Template::render("dashboard", &map))
Redirect::to("/login/discord")
})
} else { } else {
Err(Redirect::to("/login/discord")) Err(Redirect::to("/login/discord"))
} }
} }
#[get("/<_..>")] #[get("/<_..>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> {
if cookies.get_private("userid").is_some() { if cookies.get_private("userid").is_some() {
let mut map = HashMap::new(); NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| {
map.insert("version", env!("CARGO_PKG_VERSION")); warn!("Couldn't render dashboard: {:?}", e);
Ok(Template::render("dashboard", &map))
Redirect::to("/login/discord")
})
} else { } else {
Err(Redirect::to("/login/discord")) Err(Redirect::to("/login/discord"))
} }

View File

@ -31,20 +31,22 @@ pub async fn discord_login(
// store the pkce secret to verify the authorization later // store the pkce secret to verify the authorization later
cookies.add_private( cookies.add_private(
Cookie::build(("verify", pkce_verifier.secret().to_string())) Cookie::build("verify", pkce_verifier.secret().to_string())
.http_only(true) .http_only(true)
.path("/login") .path("/login")
.same_site(SameSite::Lax) .same_site(SameSite::Lax)
.expires(Expiration::Session), .expires(Expiration::Session)
.finish(),
); );
// store the csrf token to verify no interference // store the csrf token to verify no interference
cookies.add_private( cookies.add_private(
Cookie::build(("csrf", csrf_token.secret().to_string())) Cookie::build("csrf", csrf_token.secret().to_string())
.http_only(true) .http_only(true)
.path("/login") .path("/login")
.same_site(SameSite::Lax) .same_site(SameSite::Lax)
.expires(Expiration::Session), .expires(Expiration::Session)
.finish(),
); );
Redirect::to(auth_url.to_string()) Redirect::to(auth_url.to_string())
@ -52,9 +54,9 @@ pub async fn discord_login(
#[get("/discord/logout")] #[get("/discord/logout")]
pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect { pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect {
cookies.remove_private(Cookie::from("username")); cookies.remove_private(Cookie::named("username"));
cookies.remove_private(Cookie::from("userid")); cookies.remove_private(Cookie::named("userid"));
cookies.remove_private(Cookie::from("access_token")); cookies.remove_private(Cookie::named("access_token"));
Redirect::to(uri!(routes::index)) Redirect::to(uri!(routes::index))
} }
@ -78,16 +80,17 @@ pub async fn discord_callback(
.request_async(async_http_client) .request_async(async_http_client)
.await; .await;
cookies.remove_private(Cookie::from("verify")); cookies.remove_private(Cookie::named("verify"));
cookies.remove_private(Cookie::from("csrf")); cookies.remove_private(Cookie::named("csrf"));
match token_result { match token_result {
Ok(token) => { Ok(token) => {
cookies.add_private( cookies.add_private(
Cookie::build(("access_token", token.access_token().secret().to_string())) Cookie::build("access_token", token.access_token().secret().to_string())
.secure(true) .secure(true)
.http_only(true) .http_only(true)
.path("/dashboard"), .path("/dashboard")
.finish(),
); );
let request_res = reqwest_client let request_res = reqwest_client

18
web/src/routes/metrics.rs Normal file
View File

@ -0,0 +1,18 @@
use prometheus;
use crate::metrics::REGISTRY;
#[get("/metrics")]
pub async fn metrics() -> String {
let encoder = prometheus::TextEncoder::new();
let res_custom = encoder.encode_to_string(&REGISTRY.gather());
match res_custom {
Ok(s) => s,
Err(e) => {
warn!("Error encoding metrics: {:?}", e);
String::new()
}
}
}

View File

@ -1,6 +1,7 @@
pub mod admin; pub mod admin;
pub mod dashboard; pub mod dashboard;
pub mod login; pub mod login;
pub mod metrics;
pub mod report; pub mod report;
use std::collections::HashMap; use std::collections::HashMap;

View File

@ -218,17 +218,16 @@ div.inset-content {
margin-right: 10%; margin-right: 10%;
} }
div.flash-message { div.flash-container {
position: fixed; position: fixed;
width: 100%;
bottom: 0;
}
div.flash-message {
width: calc(100% - 32px); width: calc(100% - 32px);
margin: 16px !important; margin: 16px !important;
z-index: 99; z-index: 99;
bottom: 0;
display: none;
}
div.flash-message.is-active {
display: block;
} }
body { body {
@ -633,6 +632,10 @@ li.highlight {
display: flex; display: flex;
} }
.button-row-edit > button {
margin-right: 4px;
}
.button-row .button-row-reminder { .button-row .button-row-reminder {
flex-grow: 0; flex-grow: 0;
padding: 2px; padding: 2px;