add: option_source_password and option_view_password

This commit is contained in:
trisua 2025-07-25 20:26:22 -04:00
parent a49efdd238
commit 2de840f50d
5 changed files with 158 additions and 13 deletions

View file

@ -180,6 +180,11 @@ video {
color: var(--color-text); color: var(--color-text);
} }
.card-nest .card:nth-child(1) {
background: var(--color-super-raised);
padding: var(--pad-2) var(--pad-4);
}
/* button */ /* button */
.button { .button {
--h: 36px; --h: 36px;
@ -307,6 +312,10 @@ input[data-invalid] {
border-left: inset 5px var(--color-red); border-left: inset 5px var(--color-red);
} }
input.surface {
background: var(--color-surface);
}
/* typo */ /* typo */
p { p {
margin-bottom: var(--pad-4); margin-bottom: var(--pad-4);

View file

@ -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 %}")

View file

@ -24,7 +24,7 @@
("class" "w-full flex justify-between gap-2") ("class" "w-full flex justify-between gap-2")
(a (a
("class" "button") ("class" "button")
("href" "/{{ entry.slug }}/edit") ("href" "/{{ entry.slug }}/edit{% if password -%} ?key={{ password }} {%- endif %}")
(text "Edit")) (text "Edit"))
(div (div

View file

@ -95,6 +95,15 @@ pub struct EntryMetadata {
/// If this entry shows up in search engines. /// If this entry shows up in search engines.
#[serde(default, alias = "OPTION_DISABLE_SEARCH_ENGINE")] #[serde(default, alias = "OPTION_DISABLE_SEARCH_ENGINE")]
pub option_disable_search_engine: bool, 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. /// The theme that is automatically used when this entry is viewed.
#[serde(default, alias = "ACCESS_RECOMMENDED_THEME")] #[serde(default, alias = "ACCESS_RECOMMENDED_THEME")]
pub access_recommended_theme: RecommendedTheme, 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) => { ($selector:expr, $property:literal, $format:literal, $self:ident.$field:ident->$output:ident) => {
if !$self.$field.is_empty() { if !$self.$field.is_empty() {
$output.push_str(&format!( $output.push_str(&format!(
@ -404,7 +424,7 @@ macro_rules! text_size {
($selector:literal, $split:ident, $idx:literal, $output:ident) => { ($selector:literal, $split:ident, $idx:literal, $output:ident) => {
if let Some(x) = $split.get($idx) { if let Some(x) = $split.get($idx) {
if *x != "default" && *x != "0" { if *x != "default" && *x != "0" {
metadata_css!($selector, "font-size", x->$output); metadata_css!($selector, "font-size" !important, x->$output);
} }
} }
} }

View file

@ -6,7 +6,7 @@ use crate::{
}; };
use axum::{ use axum::{
Extension, Json, Router, Extension, Json, Router,
extract::Path, extract::{Path, Query},
http::{HeaderMap, HeaderValue}, http::{HeaderMap, HeaderValue},
response::{Html, IntoResponse}, response::{Html, IntoResponse},
routing::{get, get_service, post}, routing::{get, get_service, post},
@ -119,9 +119,16 @@ async fn view_doc_request(
return Html(tera.render("doc.lisp", &ctx).unwrap()); return Html(tera.render("doc.lisp", &ctx).unwrap());
} }
#[derive(Deserialize)]
pub struct ViewQuery {
#[serde(default)]
pub key: String,
}
async fn view_request( async fn view_request(
Extension(data): Extension<State>, Extension(data): Extension<State>,
Path(slug): Path<String>, Path(slug): Path<String>,
Query(props): Query<ViewQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let (ref data, ref tera, ref build_code) = *data.read().await; let (ref data, ref tera, ref build_code) = *data.read().await;
let entry = match data let entry = match data
@ -163,6 +170,15 @@ async fn view_request(
return Html(tera.render("error.lisp", &ctx).unwrap()); 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 // pull views
let views = if !metadata.option_disable_views { let views = if !metadata.option_disable_views {
match data match data
@ -207,6 +223,7 @@ async fn view_request(
ctx.insert("metadata", &metadata); ctx.insert("metadata", &metadata);
ctx.insert("metadata_head", &metadata.head_tags()); ctx.insert("metadata_head", &metadata.head_tags());
ctx.insert("metadata_css", &metadata.css()); ctx.insert("metadata_css", &metadata.css());
ctx.insert("password", &props.key);
Html(tera.render("view.lisp", &ctx).unwrap()) Html(tera.render("view.lisp", &ctx).unwrap())
} }
@ -214,6 +231,7 @@ async fn view_request(
async fn editor_request( async fn editor_request(
Extension(data): Extension<State>, Extension(data): Extension<State>,
Path(slug): Path<String>, Path(slug): Path<String>,
Query(props): Query<ViewQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let (ref data, ref tera, ref build_code) = *data.read().await; let (ref data, ref tera, ref build_code) = *data.read().await;
let entry = match data 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); let mut ctx = default_context(&data, &build_code);
ctx.insert("entry", &entry); ctx.insert("entry", &entry);
ctx.insert("password", &props.key);
Html(tera.render("edit.lisp", &ctx).unwrap()) Html(tera.render("edit.lisp", &ctx).unwrap())
} }
@ -311,6 +354,35 @@ fn default_random() -> String {
salt() 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. /// The time that must be waited between each entry creation.
const CREATE_WAIT_TIME: usize = 5000; const CREATE_WAIT_TIME: usize = 5000;
@ -318,7 +390,7 @@ async fn create_request(
jar: CookieJar, jar: CookieJar,
headers: HeaderMap, headers: HeaderMap,
Extension(data): Extension<State>, Extension(data): Extension<State>,
Json(req): Json<CreateEntry>, Json(mut req): Json<CreateEntry>,
) -> std::result::Result<impl IntoResponse, Json<ApiReturn<()>>> { ) -> std::result::Result<impl IntoResponse, Json<ApiReturn<()>>> {
let (ref data, _, _) = *data.read().await; let (ref data, _, _) = *data.read().await;
@ -382,15 +454,21 @@ async fn create_request(
} }
// check metadata // check metadata
let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { let mut metadata: EntryMetadata =
Ok(x) => x, match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())), Ok(x) => x,
}; Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())),
};
if let Err(e) = metadata.validate() { if let Err(e) = metadata.validate() {
return Err(Json(Error::MiscError(e.to_string()).into())); 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 // check for existing
if data if data
.query(&SimplifiedQuery { .query(&SimplifiedQuery {
@ -471,7 +549,7 @@ async fn edit_request(
headers: HeaderMap, headers: HeaderMap,
Extension(data): Extension<State>, Extension(data): Extension<State>,
Path(slug): Path<String>, Path(slug): Path<String>,
Json(req): Json<EditEntry>, Json(mut req): Json<EditEntry>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let (ref data, _, _) = *data.read().await; let (ref data, _, _) = *data.read().await;
@ -500,15 +578,21 @@ async fn edit_request(
} }
// check metadata // check metadata
let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { let mut metadata: EntryMetadata =
Ok(x) => x, match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
Err(e) => return Json(Error::MiscError(e.to_string()).into()), Ok(x) => x,
}; Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
if let Err(e) = metadata.validate() { if let Err(e) = metadata.validate() {
return Json(Error::MiscError(e.to_string()).into()); 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 let (id, mut entry) = match data
.query(&SimplifiedQuery { .query(&SimplifiedQuery {