From 2de840f50d733403dc9906b821ca06784b5cff01 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 25 Jul 2025 20:26:22 -0400 Subject: [PATCH] add: option_source_password and option_view_password --- app/public/style.css | 9 +++ app/templates_src/password.lisp | 32 ++++++++++ app/templates_src/view.lisp | 2 +- src/model.rs | 22 ++++++- src/routes.rs | 106 ++++++++++++++++++++++++++++---- 5 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 app/templates_src/password.lisp diff --git a/app/public/style.css b/app/public/style.css index 7008e8a..c5c8218 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -180,6 +180,11 @@ video { color: var(--color-text); } +.card-nest .card:nth-child(1) { + background: var(--color-super-raised); + padding: var(--pad-2) var(--pad-4); +} + /* button */ .button { --h: 36px; @@ -307,6 +312,10 @@ input[data-invalid] { border-left: inset 5px var(--color-red); } +input.surface { + background: var(--color-surface); +} + /* typo */ p { margin-bottom: var(--pad-4); diff --git a/app/templates_src/password.lisp b/app/templates_src/password.lisp new file mode 100644 index 0000000..d8ac928 --- /dev/null +++ b/app/templates_src/password.lisp @@ -0,0 +1,32 @@ +(text "{% extends \"root.lisp\" %} {% block head %}") +(title + (text "{{ entry.slug }}")) +(link ("rel" "icon") ("href" "/public/favicon.svg")) +(text "{% endblock %} {% block body %}") +(main + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 no_fill") + (text "{{ icon \"lock\" }}") + (b (text "Password required"))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "use_password(event)") + (div + ("class" "flex flex-collapse gap-2") + (input + ("class" "surface") + ("required" "") + ("placeholder" "Password") + ("name" "password")) + (button + ("class" "button surface") + (text "Go"))))) +(script + (text "async function use_password(event) { + event.preventDefault(); + const hash = Array.from(new Uint8Array(await window.crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(event.target.password.value)))); + const hex_hash = hash.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\"); + window.location.href = `?key=h:${hex_hash}`; + }")) +(text "{% endblock %}") diff --git a/app/templates_src/view.lisp b/app/templates_src/view.lisp index 2a352b5..f81eafa 100644 --- a/app/templates_src/view.lisp +++ b/app/templates_src/view.lisp @@ -24,7 +24,7 @@ ("class" "w-full flex justify-between gap-2") (a ("class" "button") - ("href" "/{{ entry.slug }}/edit") + ("href" "/{{ entry.slug }}/edit{% if password -%} ?key={{ password }} {%- endif %}") (text "Edit")) (div diff --git a/src/model.rs b/src/model.rs index 0c4af85..6fdb13f 100644 --- a/src/model.rs +++ b/src/model.rs @@ -95,6 +95,15 @@ pub struct EntryMetadata { /// If this entry shows up in search engines. #[serde(default, alias = "OPTION_DISABLE_SEARCH_ENGINE")] pub option_disable_search_engine: bool, + /// The password that is required to view this entry. + #[serde(default, alias = "OPTION_VIEW_PASSWORD")] + pub option_view_password: String, + /// The password that is required to view the source of the entry. + /// + /// If no password is provided but a view password IS provided, the view + /// password will be used. + #[serde(default, alias = "OPTION_SOURCE_PASSWORD")] + pub option_source_password: String, /// The theme that is automatically used when this entry is viewed. #[serde(default, alias = "ACCESS_RECOMMENDED_THEME")] pub access_recommended_theme: RecommendedTheme, @@ -388,6 +397,17 @@ macro_rules! metadata_css { } }; + ($selector:expr, $property:literal !important, $field:ident->$output:ident) => { + if !$field.is_empty() { + $output.push_str(&format!( + "{} {{ {}: {} !important; }}\n", + $selector, + $property, + EntryMetadata::css_escape(&$field) + )); + } + }; + ($selector:expr, $property:literal, $format:literal, $self:ident.$field:ident->$output:ident) => { if !$self.$field.is_empty() { $output.push_str(&format!( @@ -404,7 +424,7 @@ macro_rules! text_size { ($selector:literal, $split:ident, $idx:literal, $output:ident) => { if let Some(x) = $split.get($idx) { if *x != "default" && *x != "0" { - metadata_css!($selector, "font-size", x->$output); + metadata_css!($selector, "font-size" !important, x->$output); } } } diff --git a/src/routes.rs b/src/routes.rs index 1bdc20a..1ed8c84 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -6,7 +6,7 @@ use crate::{ }; use axum::{ Extension, Json, Router, - extract::Path, + extract::{Path, Query}, http::{HeaderMap, HeaderValue}, response::{Html, IntoResponse}, routing::{get, get_service, post}, @@ -119,9 +119,16 @@ async fn view_doc_request( return Html(tera.render("doc.lisp", &ctx).unwrap()); } +#[derive(Deserialize)] +pub struct ViewQuery { + #[serde(default)] + pub key: String, +} + async fn view_request( Extension(data): Extension, Path(slug): Path, + Query(props): Query, ) -> impl IntoResponse { let (ref data, ref tera, ref build_code) = *data.read().await; let entry = match data @@ -163,6 +170,15 @@ async fn view_request( return Html(tera.render("error.lisp", &ctx).unwrap()); } + // ... + if !metadata.option_view_password.is_empty() + && metadata.option_view_password != props.key.clone() + { + let mut ctx = default_context(&data, &build_code); + ctx.insert("entry", &entry); + return Html(tera.render("password.lisp", &ctx).unwrap()); + } + // pull views let views = if !metadata.option_disable_views { match data @@ -207,6 +223,7 @@ async fn view_request( ctx.insert("metadata", &metadata); ctx.insert("metadata_head", &metadata.head_tags()); ctx.insert("metadata_css", &metadata.css()); + ctx.insert("password", &props.key); Html(tera.render("view.lisp", &ctx).unwrap()) } @@ -214,6 +231,7 @@ async fn view_request( async fn editor_request( Extension(data): Extension, Path(slug): Path, + Query(props): Query, ) -> impl IntoResponse { let (ref data, ref tera, ref build_code) = *data.read().await; let entry = match data @@ -238,9 +256,34 @@ async fn editor_request( } }; + let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata)) + { + Ok(x) => x, + Err(e) => { + let mut ctx = default_context(&data, &build_code); + ctx.insert("error", &e.to_string()); + return Html(tera.render("error.lisp", &ctx).unwrap()); + } + }; + + // ... + if if !metadata.option_source_password.is_empty() { + metadata.option_source_password != props.key + } else if !metadata.option_view_password.is_empty() { + metadata.option_view_password != props.key + } else { + false + } { + let mut ctx = default_context(&data, &build_code); + ctx.insert("entry", &entry); + return Html(tera.render("password.lisp", &ctx).unwrap()); + } + // ... let mut ctx = default_context(&data, &build_code); + ctx.insert("entry", &entry); + ctx.insert("password", &props.key); Html(tera.render("edit.lisp", &ctx).unwrap()) } @@ -311,6 +354,35 @@ fn default_random() -> String { salt() } +fn hash_passwords(metadata: &mut EntryMetadata) -> (bool, String) { + // hash passwords + let do_update_metadata = (!metadata.option_view_password.is_empty() + || !metadata.option_source_password.is_empty()) + && (!metadata.option_view_password.starts_with("h:") + || !metadata.option_source_password.starts_with("h:")); + + if !metadata.option_view_password.is_empty() && !metadata.option_view_password.starts_with("h:") + { + metadata.option_view_password = + format!("h:{}", hash(metadata.option_view_password.clone())); + } + + if !metadata.option_source_password.is_empty() + && !metadata.option_source_password.starts_with("h:") + { + metadata.option_source_password = + format!("h:{}", hash(metadata.option_source_password.clone())); + } + + if do_update_metadata { + if let Ok(x) = toml::to_string_pretty(&metadata) { + return (true, x); + }; + } + + (false, String::new()) +} + /// The time that must be waited between each entry creation. const CREATE_WAIT_TIME: usize = 5000; @@ -318,7 +390,7 @@ async fn create_request( jar: CookieJar, headers: HeaderMap, Extension(data): Extension, - Json(req): Json, + Json(mut req): Json, ) -> std::result::Result>> { let (ref data, _, _) = *data.read().await; @@ -382,15 +454,21 @@ async fn create_request( } // check metadata - let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { - Ok(x) => x, - Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())), - }; + let mut metadata: EntryMetadata = + match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { + Ok(x) => x, + Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())), + }; if let Err(e) = metadata.validate() { return Err(Json(Error::MiscError(e.to_string()).into())); } + let (do_update_metadata, updated) = hash_passwords(&mut metadata); + if do_update_metadata { + req.metadata = updated; + } + // check for existing if data .query(&SimplifiedQuery { @@ -471,7 +549,7 @@ async fn edit_request( headers: HeaderMap, Extension(data): Extension, Path(slug): Path, - Json(req): Json, + Json(mut req): Json, ) -> impl IntoResponse { let (ref data, _, _) = *data.read().await; @@ -500,15 +578,21 @@ async fn edit_request( } // check metadata - let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { - Ok(x) => x, - Err(e) => return Json(Error::MiscError(e.to_string()).into()), - }; + let mut metadata: EntryMetadata = + match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; if let Err(e) = metadata.validate() { return Json(Error::MiscError(e.to_string()).into()); } + let (do_update_metadata, updated) = hash_passwords(&mut metadata); + if do_update_metadata { + req.metadata = updated; + } + // ... let (id, mut entry) = match data .query(&SimplifiedQuery {