diff --git a/app/public/style.css b/app/public/style.css index 293ca64..9c219c6 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -264,12 +264,19 @@ video { display: flex; } -.dropdown .inner .button { +.dropdown .inner .button, +.dropdown .inner .title { padding: var(--pad-3) var(--pad-4); justify-content: flex-start; width: 100%; } +.dropdown .inner .title { + font-weight: 500; + border-top: solid 1px var(--color-super-lowered); + margin-top: var(--pad-2); +} + .dropdown:has(.inner.open) .button:nth-child(1):not(.inner *) { background: var(--color-raised); } @@ -517,6 +524,18 @@ img { vertical-align: middle; } +.img_sizer img { + width: 100%; + height: 100%; +} + +.avatar { + --size: 18px; + width: var(--size); + height: var(--size); + aspect-ratio: 1 / 1; +} + blockquote { padding-left: 1rem; border-left: solid 5px var(--color-green); diff --git a/app/templates_src/claim.lisp b/app/templates_src/claim.lisp new file mode 100644 index 0000000..bff9a83 --- /dev/null +++ b/app/templates_src/claim.lisp @@ -0,0 +1,39 @@ +(text "{% extends \"root.lisp\" %} {% block head %}") +(title + (text "Create reclaim for \"{{ entry.slug }}\" - {{ name }}")) +(link ("rel" "icon") ("href" "/public/favicon.svg")) +(text "{% endblock %} {% block body %}") +(div + ("class" "card container") + (h1 (text "{{ entry.slug }}")) + (p (text "Custom slug reclaims are handled through ") (b (text "{{ tetratto }}")) (text ". You'll need to have an account there to submit a claim request.")) + (p (text "Please note that you are unlikely to receive a response unless your claim is accepted. Please do not submit additional requests for the same slug.")) + + (text "{% if metadata.tetratto_owner_username -%}") + ; contact owner text + (p (text "Since this entry is connected to a user, it is encouraged that you directly contact the owner of the entry instead. If that doesn't work, you can create a regular request.")) + (text "{%- endif %}") + + (p (text "Once you're ready, you can submit a claim using the button below.")) + (hr) + (ul + (li (b (text "Requsted slug: ")) (text "{{ entry.slug }}")) + (li (b (text "Last updated: ")) (text "{{ entry.edited / 1000|int|date(format=\"%Y-%m-%d %H:%M\", timezone=\"Etc/UTC\") }} UTC"))) + (hr) + (text "{% if claimable -%}") + (text "{% if metadata.tetratto_owner_username -%}") + ; contact owner button + (a + ("href" "{{ tetratto }}/mail/compose?receivers={{ tetratto_owner_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22") + ("class" "button surface no_fill") + (text "{{ icon \"external-link\" }} Contact owner")) + (text "{%- endif %}") + + (a + ("href" "{{ tetratto }}/mail/compose?receivers={{ tetratto_handler_account_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22") + ("class" "button surface no_fill") + (text "{{ icon \"external-link\" }} Submit request")) + (text "{% else %}") + (span (text "This slug is currently not claimable as it was edited too recently. ") (a ("href" "/{{ entry.slug }}") ("class" "red") (text "Go back"))) + (text "{%- endif %}")) +(text "{% endblock %}") diff --git a/app/templates_src/root.lisp b/app/templates_src/root.lisp index b806505..12eb5b9 100644 --- a/app/templates_src/root.lisp +++ b/app/templates_src/root.lisp @@ -45,7 +45,8 @@ (a ("class" "button") ("href" "https://trisua.com/t/fluffle") - (text "source")))) + (text "source")) + (text "{% block dropdown %}{% endblock %}"))) (a ("class" "button camo fade") diff --git a/app/templates_src/view.lisp b/app/templates_src/view.lisp index 40fc46f..5dff22c 100644 --- a/app/templates_src/view.lisp +++ b/app/templates_src/view.lisp @@ -12,7 +12,6 @@ (text "{% if metadata.page_icon|length == 0 -%}") (link ("rel" "icon") ("href" "/public/favicon.svg")) (text "{%- endif %}") - (text "{% endblock %} {% block body %}") (div ("class" "flex flex_col gap_2") @@ -40,6 +39,20 @@ (script ("defer" "true") (text "setTimeout(() => { temporary_set_theme('{{ metadata.access_recommended_theme }}') }, 150);")) (text "{%- endif %}") + ; owner + (text "{% if metadata.tetratto_owner_username|length > 0 -%}") + (span + ("class" "flex items_center gap_2") + (text "Owner:") + (a + ("class" "flex items_center gap_2") + ("href" "{{ tetratto }}/@{{ metadata.tetratto_owner_username }}") + (img + ("class" "avatar") + ("src" "{{ tetratto }}/api/v1/auth/user/{{ metadata.tetratto_owner_username }}/avatar?selector_type=username")) + (text "{{ metadata.tetratto_owner_username }}"))) + (text "{%- endif %}") + ; views (text "{% if not metadata.option_disable_views -%}") (span (text "Views: {{ views }}")) @@ -55,7 +68,6 @@ (link ("rel" "stylesheet") ("href" "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css")) (script ("src" "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js")) (script (text "hljs.highlightAll();")) - (text "{% endblock %}") (text "{% block nav_extras %}") (button @@ -72,3 +84,11 @@ }, 150);")) (text "{%- endif %}") (text "{% endblock %}") + +(text "{% block dropdown %}") +(hr) +(a + ("class" "button") + ("href" "/{{ entry.slug }}/claim") + (text "claim")) +(text "{%- endblock %}") diff --git a/src/markdown.rs b/src/markdown.rs index 9c32c41..13b7b4e 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -3,8 +3,8 @@ use std::collections::HashSet; pub fn render_markdown(input: &str) -> String { let html = tetratto_shared::markdown::render_markdown_dirty(&parse_page(&parse_details( &parse_text_color(&parse_highlight(&parse_link(&parse_image( - &parse_image_size(&parse_toc(&parse_underline(&parse_comment( - &input.replace("[/]", "
"), + &parse_image_size(&parse_toc(&parse_underline(&parse_markdown_element( + &parse_comment(&input.replace("[/]", "
")), )))), )))), ))) @@ -983,3 +983,26 @@ pub fn parse_toc(input: &str) -> String { output } + +/// Handle the `` HTML element. +fn parse_markdown_element_line(output: &mut String, buffer: &mut String, line: &str) { + let mut in_markdown = false; + + for char in line.chars() { + if buffer.ends_with("") { + in_markdown = true; + output.push_str(&buffer.replace("", "")); + buffer.clear(); + } else if in_markdown && buffer.ends_with("") { + in_markdown = false; + output.push_str(&render_markdown(&buffer.replace("", ""))); + buffer.clear(); + } + + buffer.push(char); + } +} + +pub fn parse_markdown_element(input: &str) -> String { + parser_ignores_pre!(parse_markdown_element_line, input) +} diff --git a/src/model.rs b/src/model.rs index ad8a348..72db41f 100644 --- a/src/model.rs +++ b/src/model.rs @@ -371,6 +371,13 @@ pub struct EntryMetadata { #[serde(default, alias = "SAFETY_CONTENT_WARNING")] #[validate(max_length = 512)] pub safety_content_warning: String, + /// The username of the owner of this entry on the Tetratto instance. + /// + /// For security reasons, this really does nothing but show the owner in the + /// entry's info section. + #[serde(default, alias = "TETRATTO_OWNER_USERNAME")] + #[validate(max_length = 32)] + pub tetratto_owner_username: String, } macro_rules! metadata_css { diff --git a/src/routes.rs b/src/routes.rs index 64029f6..553ee01 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -42,6 +42,7 @@ pub fn routes() -> Router { .route("/", get(index_request)) .route("/{slug}", get(view_request)) .route("/{slug}/edit", get(editor_request)) + .route("/{slug}/claim", get(reclaim_request)) // api .route("/api/v1/util/ip", get(util_ip)) .route("/api/v1/render", post(render_request)) @@ -62,6 +63,10 @@ fn default_context(data: &DataClient, build_code: &str) -> Context { "what_page_slug", &var("WHAT_SLUG").unwrap_or("what".to_string()), ); + ctx.insert( + "tetratto_handler_account_username", + &var("TETRATTO_HANDLER_ACCOUNT_USERNAME").unwrap_or("fluffle".to_string()), + ); ctx.insert("build_code", &build_code); ctx } @@ -300,6 +305,63 @@ async fn editor_request( Html(tera.render("edit.lisp", &ctx).unwrap()) } +const MINIMUM_CLAIMABLE_TIME: usize = 604_800_000; // 1 week + +async fn reclaim_request( + Extension(data): Extension, + Path(mut slug): Path, +) -> impl IntoResponse { + let (ref data, ref tera, ref build_code) = *data.read().await; + slug = slug.to_lowercase(); + + let entry = match data + .query(&SimplifiedQuery { + query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())), + mode: AppDataSelectMode::One(0), + }) + .await + { + Ok(r) => match r { + AppDataQueryResult::One(r) => serde_json::from_str::(&r.value).unwrap(), + AppDataQueryResult::Many(_) => unreachable!(), + }, + Err(_) => { + let mut ctx = default_context(&data, &build_code); + ctx.insert( + "error", + &Error::GeneralNotFound("entry".to_string()).to_string(), + ); + + return Html(tera.render("error.lisp", &ctx).unwrap()); + } + }; + + // check metadata + let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata)) + { + Ok(x) => x, + Err(_) => EntryMetadata::default(), + }; + + if let Err(e) = metadata.validate() { + let mut ctx = default_context(&data, &build_code); + ctx.insert("error", &e.to_string()); + return Html(tera.render("error.lisp", &ctx).unwrap()); + } + + // ... + let mut ctx = default_context(&data, &build_code); + + ctx.insert("entry", &entry); + ctx.insert("metadata", &metadata); + ctx.insert( + "claimable", + &((unix_epoch_timestamp() - entry.edited) > MINIMUM_CLAIMABLE_TIME), + ); + + Html(tera.render("claim.lisp", &ctx).unwrap()) +} + // api #[derive(Deserialize)] struct RenderMarkdown {