diff --git a/Cargo.lock b/Cargo.lock index 079561e..8488b22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2551,9 +2551,9 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "12.0.1" +version = "12.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c1b01499f7471ee6f05299c06ebb18760440452714c6f9a6c0c9e0cf9a663bd" +checksum = "a367ac3ced8ff302080e1b4a82a67acd24fa606245c4381a6f77dbaaf6ef4b58" dependencies = [ "async-recursion", "base16ct", @@ -2570,6 +2570,7 @@ dependencies = [ "serde_json", "tetratto-l10n", "tetratto-shared", + "tokio", "toml 0.9.2", "totp-rs", ] @@ -2587,9 +2588,9 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "12.0.5" +version = "12.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11c2ba2be9c92a4ac566b9c3b615d7311b3d0e98b175ad84e81b44644b34dd8f" +checksum = "286290ad09be3c507f9a47d38e92b024e6fcde34dbb515113f5bdb6b926cbee3" dependencies = [ "ammonia", "chrono", diff --git a/Cargo.toml b/Cargo.toml index de66da2..75e7458 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,8 @@ license = "AGPL-3.0-or-later" homepage = "https://tetratto.com" [dependencies] -tetratto-core = "12.0.1" -tetratto-shared = "12.0.5" +tetratto-core = "12.0.2" +tetratto-shared = "12.0.6" tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..01c9893 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +public/docs/**/* +!public/docs/metadata.md +icons/ diff --git a/app/docs/metadata.md b/app/docs/metadata.md new file mode 100644 index 0000000..18e8f46 --- /dev/null +++ b/app/docs/metadata.md @@ -0,0 +1,11 @@ +# What is metadata? + +Each entry also includes a separate metadata content. Metadata is used to further customize the look and display of your entries to a finer degree than you can through just Markdown. + +Metadata includes options to customize how your entries look when shared to other platforms, as well as to customize how your entries render when viewed. + +All option names can either be in all lowercase, or all uppercase. While option values for `String` type metadata options _should_ be encased in double quotes, that will be done for you if you leave them out. This is included just for compatibility. + +Metadata options go in the "Metadata" tab in the entry editor page. Each option should be on a new line, and should be formatted as `NAME = value`. If you're familiar with TOML, you should be comfortable with metadata formatting. + +You can view a list of all options and what they do [here](http://localhost:9119/public/reference/attobin/model/struct.EntryMetadata.html#fields). diff --git a/app/public/app.js b/app/public/app.js index c5305b3..e6ccff9 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -224,3 +224,109 @@ globalThis.check_exists_input = (e) => { e.target.reportValidity(); }, 1000); }; + +// components +function close_dropdowns() { + for (const dropdown of Array.from( + document.querySelectorAll(".inner.open"), + )) { + dropdown.classList.remove("open"); + } +} + +globalThis.open_dropdown = (event) => { + event.stopImmediatePropagation(); + let target = event.target; + + while (!target.matches(".dropdown")) { + target = target.parentElement; + } + + // close all others + close_dropdowns(); + + // open + setTimeout(() => { + for (const dropdown of Array.from(target.querySelectorAll(".inner"))) { + // check y + const box = target.getBoundingClientRect(); + + let parent = dropdown.parentElement; + + while (!parent.matches("html, .window")) { + parent = parent.parentElement; + } + + let parent_height = parent.getBoundingClientRect().y; + + if (parent.nodeName === "HTML") { + parent_height = window.screen.height; + } + + const scroll = window.scrollY; + const height = parent_height; + const y = box.y + scroll; + + if (y > height - scroll - 375) { + dropdown.classList.add("top"); + } else { + dropdown.classList.remove("top"); + } + + // open + dropdown.classList.add("open"); + + if (dropdown.classList.contains("open")) { + dropdown.removeAttribute("aria-hidden"); + } else { + dropdown.setAttribute("aria-hidden", "true"); + } + } + }, 5); +}; + +globalThis.init_dropdowns = (bind_to) => { + for (const dropdown of Array.from(document.querySelectorAll(".inner"))) { + dropdown.setAttribute("aria-hidden", "true"); + } + + bind_to.addEventListener("click", (event) => { + if ( + event.target.matches(".dropdown") || + event.target.matches("[exclude=dropdown]") + ) { + return; + } + + for (const dropdown of Array.from( + document.querySelectorAll(".inner.open"), + )) { + dropdown.classList.remove("open"); + } + }); +}; + +globalThis.METADATA_CSS_ENABLED = true; +globalThis.toggle_metadata_css = (e) => { + e.target.classList.add("yellow"); + + METADATA_CSS_ENABLED = !METADATA_CSS_ENABLED; + if (!METADATA_CSS_ENABLED) { + media_theme_pref(); // user user theme + document.getElementById("metadata_css").remove(); // remove css + + // reset colored text + for (const element of Array.from( + document.querySelectorAll(".color_block"), + )) { + element.removeAttribute("style"); + element.classList.remove("color_block"); + } + + // strikethrough auto theme since it's disabled + document.getElementById("auto_theme").style.textDecoration = + "line-through"; + } else { + window.location.reload(); + } +}; diff --git a/app/public/reference b/app/public/reference new file mode 120000 index 0000000..bb0525b --- /dev/null +++ b/app/public/reference @@ -0,0 +1 @@ +../../target/doc \ No newline at end of file diff --git a/app/public/style.css b/app/public/style.css index 4afe582..9b60cf3 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -12,7 +12,7 @@ --color-shadow: rgba(0, 0, 0, 0.08); --color-red: hsl(0, 84%, 40%); --color-green: hsl(100, 84%, 20%); - --color-yellow: hsl(41, 63%, 75%); + --color-yellow: oklch(47% 0.157 37.304); --color-purple: hsl(284, 84%, 20%); --color-primary: oklch(67.3% 0.182 276.935); @@ -24,6 +24,9 @@ --pad-2: 0.35rem; --pad-3: 0.5rem; --pad-4: 1rem; + + --radius: 0.2rem; + --nav-height: 36px; } * { @@ -45,7 +48,7 @@ --color-link: #93c5fd; --color-red: hsl(0, 94%, 82%); --color-green: hsl(100, 94%, 82%); - --color-yellow: hsl(41, 63%, 65%); + --color-yellow: oklch(90.1% 0.076 70.697); --color-purple: hsl(284, 94%, 82%); } @@ -73,7 +76,7 @@ main { article { margin: var(--pad-2) 0; - height: calc(100dvh - var(--pad-4) * 2); + height: calc(100dvh - var(--pad-4) - var(--nav-height) * 2); } .tab { @@ -89,6 +92,14 @@ article { animation: fadein ease-in-out 1 0.5s forwards running; } +nav { + /* background: var(--color-raised); */ + height: var(--nav-height); + /* position: sticky; + z-index: 2; + top: 2; */ +} + @media screen and (max-width: 900px) { main, article, @@ -117,7 +128,7 @@ article { } .content_container { - margin: var(--pad-2) auto; + margin: 0 auto var(--pad-2); width: 100%; } @@ -167,7 +178,7 @@ video { display: flex; justify-content: center; align-items: center; - gap: var(--gap-2); + gap: var(--pad-2); padding: var(--pad-2) calc(var(--pad-3) * 1.5); cursor: pointer; background: var(--color-raised); @@ -187,7 +198,7 @@ video { --h: 28px; } -.button:hover { +.button:not(:has(.button:hover)):not(.camo):hover { background: var(--color-super-raised); } @@ -196,10 +207,70 @@ video { color: inherit; } -.bar .button.camo:hover { +.bar .button:not(.simple).camo:hover { color: var(--color-link); } +.button.simple { + --size: 18px; + font-weight: 600; + padding: var(--pad-2) !important; + border-radius: var(--radius); + width: var(--size); + height: var(--size); + aspect-ratio: 1 / 1; + font-size: 12px; +} + +.button.surface { + background: var(--color-surface); +} + +.button.surface.simple:is(.camo *) { + background: var(--color-super-raised); +} + +/* dropdown */ +.dropdown { + position: relative; +} + +.dropdown .inner { + display: none; + flex-direction: column; + box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size) + var(--color-shadow); + background: var(--color-raised); + color: inherit; + position: absolute; + z-index: 2; + top: 100%; +} + +.dropdown .inner.open { + display: flex; +} + +.dropdown .inner .button { + padding: var(--pad-3) var(--pad-4); + justify-content: flex-start; + width: 100%; +} + +.dropdown:has(.inner.open) .button:nth-child(1):not(.inner *) { + background: var(--color-raised); +} + +.dropdown .inner.top { + top: unset; + bottom: 100%; +} + +.dropdown .inner.left { + left: 0; + right: unset; +} + /* input */ input { --h: 36px; @@ -276,6 +347,7 @@ pre { padding: var(--pad-2) var(--pad-4); border-left: solid 5px var(--color-primary); background: var(--color-surface); + border-radius: var(--radius); } code { @@ -297,8 +369,22 @@ code * { font-size: 0.8rem !important; } +code:not(pre *) { + padding: var(--pad-1) var(--pad-2); + background: oklch(98% 0.016 73.684 / 25%); + color: oklch(90.1% 0.076 70.697); + border-radius: var(--radius); + white-space: break-spaces; +} + +code:not(pre *):not(.dark *) { + background: oklch(83.7% 0.128 66.29 / 25%); + color: oklch(47% 0.157 37.304); +} + svg.icon { stroke: currentColor; + fill: currentColor; width: 18px; height: 1em; } @@ -307,6 +393,10 @@ svg.icon.filled { fill: currentColor; } +.no_fill svg.icon { + fill: transparent; +} + button svg { pointer-events: none; } @@ -384,6 +474,10 @@ a { color: var(--color-link); } +.color_block a { + color: inherit; +} + a.flush { color: inherit; } @@ -477,12 +571,12 @@ blockquote { .cm-comment, .hljs-keyword { - color: rgb(153 27 27) !important; + color: oklch(47% 0.157 37.304) !important; } .cm-comment:is(.dark *), .hljs-keyword:is(.dark *) { - color: rgb(254, 202, 202) !important; + color: oklch(90.1% 0.076 70.697) !important; } .cm-link { diff --git a/app/templates_src/components.lisp b/app/templates_src/components.lisp deleted file mode 100644 index 75b6938..0000000 --- a/app/templates_src/components.lisp +++ /dev/null @@ -1,40 +0,0 @@ -(text "{% macro footer() -%}") -(footer - ("class" "flex flex-col items-center gap-2") - (hr ("style" "min-width: 20rem; margin-top: calc(var(--pad-4) * 4)")) - (div - ("class" "w-full flex justify-between") - (div ("style" "width: 50px")) - (div - ("class" "flex flex-col gap-2 items-center") - (div - ("class" "flex gap-2 flex-wrap") - (a - ("href" "/") - (text "new")) - - (a - ("href" "/{{ what_page_slug }}") - (text "what")) - - (a - ("href" "https://trisua.com/t/attobin") - (text "source"))) - - (span ("style" "font-size: 14px") ("class" "fade") (text "{{ name }}"))) - - ; theme switches - (button - ("class" "button camo fade") - ("id" "switch_light") - ("title" "Switch theme") - ("onclick" "set_theme('Dark')") - (text "")) - - (button - ("class" "button camo fade hidden") - ("id" "switch_dark") - ("title" "Switch theme") - ("onclick" "set_theme('Light')") - (text "")))) -(text "{%- endmacro %}") diff --git a/app/templates_src/doc.lisp b/app/templates_src/doc.lisp new file mode 100644 index 0000000..7c1f541 --- /dev/null +++ b/app/templates_src/doc.lisp @@ -0,0 +1,9 @@ +(text "{% extends \"root.lisp\" %} {% block head %}") +(title + (text "{{ file_name }} - {{ name }}")) +(link ("rel" "icon") ("href" "/public/favicon.svg")) +(text "{% endblock %} {% block body %}") +(div + ("class" "card container") + (p (text "{{ text|markdown|safe }}"))) +(text "{% endblock %}") diff --git a/app/templates_src/edit.lisp b/app/templates_src/edit.lisp index 1840dcd..fc6ea85 100644 --- a/app/templates_src/edit.lisp +++ b/app/templates_src/edit.lisp @@ -19,7 +19,13 @@ ("class" "button camo tab_button") ("id" "metadata_tab_button") ("onclick" "tab_metadata()") - (text "Metadata"))) + (text "Metadata") + (a + ("class" "button simple surface") + ("href" "/docs/metadata") + ("target" "_blank") + ("title" "Info") + (text "i")))) (div ("class" "card tab tabs container") ("id" "tabs_group") @@ -74,7 +80,6 @@ ("class" "button red") ("ui_ident" "delete") (text "Delete")))) -(text "{{ components::footer() }}") ; editor (script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js")) diff --git a/app/templates_src/error.lisp b/app/templates_src/error.lisp index 4cca2cb..23b614a 100644 --- a/app/templates_src/error.lisp +++ b/app/templates_src/error.lisp @@ -6,5 +6,4 @@ (div ("class" "card") (p (text "{{ error }}"))) -(text "{{ components::footer() }}") (text "{% endblock %}") diff --git a/app/templates_src/index.lisp b/app/templates_src/index.lisp index f928864..f5cf36c 100644 --- a/app/templates_src/index.lisp +++ b/app/templates_src/index.lisp @@ -1,6 +1,9 @@ (text "{% extends \"root.lisp\" %} {% block head %}") (title (text "{{ name }}")) + +(meta ("property" "og:title") ("content" "{{ name }}")) +(meta ("property" "twitter:title") ("content" "{{ name }}")) (link ("rel" "icon") ("href" "/public/favicon.svg")) (text "{% endblock %} {% block body %}") (div @@ -19,7 +22,13 @@ ("class" "button camo tab_button") ("id" "metadata_tab_button") ("onclick" "tab_metadata()") - (text "Metadata"))) + (text "Metadata") + (a + ("class" "button simple surface") + ("href" "/docs/metadata") + ("target" "_blank") + ("title" "Info") + (text "i")))) (div ("class" "card tab tabs container") ("id" "tabs_group") @@ -55,7 +64,6 @@ ("name" "slug") ("oninput" "check_exists_input(event)") ("placeholder" "Custom url")))) -(text "{{ components::footer() }}") ; editor (script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js")) diff --git a/app/templates_src/root.lisp b/app/templates_src/root.lisp index fc016b7..0b993b0 100644 --- a/app/templates_src/root.lisp +++ b/app/templates_src/root.lisp @@ -1,4 +1,3 @@ -(text "{%- import \"components.lisp\" as components -%}") (text "") (html ("lang" "en") @@ -19,8 +18,64 @@ (text "{% block head %}{% endblock %}")) (body + ; nav + (nav + ("class" "flex w-full justify-between gap-2") + (div + ("class" "flex side") + (div + ("class" "dropdown") + (button + ("onclick" "open_dropdown(event)") + ("exclude" "dropdown") + ("class" "button camo fade") + (text "{{ icon \"menu\" }}")) + (div + ("class" "inner") + (a + ("class" "button") + ("href" "/") + (text "new")) + (a + ("class" "button") + ("href" "/{{ what_page_slug }}") + (text "what")) + (a + ("class" "button") + ("href" "https://trisua.com/t/attobin") + (text "source")))) + + (a + ("class" "button camo fade") + ("href" "/") + ("title" "new") + (text "{{ icon \"plus\" }}"))) + + (div + ("class" "side flex") + (text "{% block nav_extras %}{% endblock %}") + + ; theme switches + (button + ("class" "button camo fade") + ("id" "switch_light") + ("title" "Switch theme") + ("onclick" "set_theme('Dark')") + (text "{{ icon \"sun\" }}")) + + (button + ("class" "button camo fade hidden") + ("id" "switch_dark") + ("title" "Switch theme") + ("onclick" "set_theme('Light')") + (text "{{ icon \"moon\" }}")))) + + ; page (article ("class" "content_container flex flex-col") ("id" "page") (ul ("id" "messages")) - (text "{% block body %}{% endblock %}")))) + (text "{% block body %}{% endblock %}") + (div ("style" "min-height: 32px"))) + + (script (text "setTimeout(() => init_dropdowns(document.body), 150);")))) diff --git a/app/templates_src/view.lisp b/app/templates_src/view.lisp index e3c252e..2a352b5 100644 --- a/app/templates_src/view.lisp +++ b/app/templates_src/view.lisp @@ -4,6 +4,11 @@ (text "{{ entry.slug }}")) (text "{%- endif %} {{ metadata_head|safe }}") +(text "{% if not metadata.share_title -%}") +(meta ("property" "og:title") ("content" "{{ entry.slug }}")) +(meta ("property" "twitter:title") ("content" "{{ entry.slug }}")) +(text "{%- endif %}") + (text "{% if metadata.page_icon|length == 0 -%}") (link ("rel" "icon") ("href" "/public/favicon.svg")) (text "{%- endif %}") @@ -30,7 +35,7 @@ ; auto theme (text "{% if metadata.access_recommended_theme != 'None' -%}") - (span (text "Auto theme: {{ metadata.access_recommended_theme }}")) + (span ("id" "auto_theme") (text "Auto theme: {{ metadata.access_recommended_theme }}")) (script ("defer" "true") (text "setTimeout(() => { temporary_set_theme('{{ metadata.access_recommended_theme }}') }, 150);")) (text "{%- endif %}") @@ -44,11 +49,17 @@ (a ("class" "button small") ("href" "/{{ metadata.access_easy_read }}") (b (text "E2R"))) (text "{%- endif %}")))) -(text "{{ metadata_css|safe }}") +(div ("style" "display: none") ("id" "metadata_css") (text "{{ metadata_css|safe }}")) (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 "{{ components::footer() }}") +(text "{% endblock %}") +(text "{% block nav_extras %}") +(button + ("class" "button camo fade no_fill") + ("title" "Toggle high-contrast") + ("onclick" "toggle_metadata_css(event)") + (text "{{ icon \"contrast\" }}")) (text "{% endblock %}") diff --git a/justfile b/justfile new file mode 100644 index 0000000..7975439 --- /dev/null +++ b/justfile @@ -0,0 +1,6 @@ +doc: + cargo doc --no-deps --document-private-items + +build: + just doc + cargo build -r diff --git a/src/main.rs b/src/main.rs index 7aabae8..7d50353 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use axum::{Extension, Router}; use nanoneo::core::element::Render; use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc}; use tera::{Tera, Value}; -use tetratto_core::sdk::DataClient; +use tetratto_core::{html, sdk::DataClient}; use tetratto_shared::hash::salt; use tokio::sync::RwLock; use tower_http::{ @@ -64,20 +64,29 @@ async fn main() { // build lisp create_dir_if_not_exists!("./templates_build"); + create_dir_if_not_exists!("./icons"); + for x in glob::glob("./templates_src/**/*").expect("failed to read pattern") { match x { Ok(x) => std::fs::write( x.to_str() .unwrap() .replace("templates_src/", "templates_build/"), - nanoneo::parse(&std::fs::read_to_string(x).expect("failed to read template")) - .render(&mut HashMap::new()), + html::pull_icons( + nanoneo::parse(&std::fs::read_to_string(x).expect("failed to read template")) + .render(&mut HashMap::new()), + "./icons", + ) + .await, ) .expect("failed to write template"), Err(e) => panic!("{e}"), } } + // create docs dir + create_dir_if_not_exists!("./docs"); + // ... let mut tera = match Tera::new(&format!("./templates_build/**/*")) { Ok(t) => t, diff --git a/src/markdown.rs b/src/markdown.rs index 07510b2..d72df10 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -76,7 +76,7 @@ fn parse_text_color_line(output: &mut String, buffer: &mut String, line: &str) { // by this point, we have: ! // %color_buffer%main_buffer%% output.push_str(&format!( - "{buffer}" + "{buffer}" )); color_buffer.clear(); @@ -468,7 +468,14 @@ fn parse_image_line(output: &mut String, buffer: &mut String, line: &str) { if in_image { // end output.push_str(&format!( - "\"{alt}\"" + "\"{alt}\"", + if buffer.ends_with("#left") { + "left" + } else if buffer.ends_with("#right") { + "right" + } else { + "unset" + } )); alt = String::new(); diff --git a/src/model.rs b/src/model.rs index 2d1e041..bc763d8 100644 --- a/src/model.rs +++ b/src/model.rs @@ -357,6 +357,17 @@ macro_rules! metadata_css { } }; + ($selector:literal, $property:literal !important, $self:ident.$field:ident->$output:ident) => { + if !$self.$field.is_empty() { + $output.push_str(&format!( + "{} {{ {}: {} !important; }}\n", + $selector, + $property, + EntryMetadata::css_escape(&$self.$field) + )); + } + }; + ($selector:literal, $property:literal, $format:literal, $self:ident.$field:ident->$output:ident) => { if !$self.$field.is_empty() { $output.push_str(&format!( @@ -462,7 +473,7 @@ impl EntryMetadata { metadata_css!(".container", "border-radius", self.container_border_radius->output); metadata_css!(".container", "box-shadow", self.container_shadow->output); metadata_css!(".container", "text-shadow", self.content_text_shadow->output); - metadata_css!(".container a", "color", self.content_text_link_color->output); + metadata_css!("*, html *", "--color-link" !important, self.content_text_link_color->output); if self.content_text_align != TextAlignment::Left { output.push_str(&format!( diff --git a/src/routes.rs b/src/routes.rs index e4db1e5..158f1b9 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -8,6 +8,8 @@ use axum::{ response::{Html, IntoResponse}, routing::{get, get_service, post}, }; +use axum_extra::extract::CookieJar; +use pathbufd::PathBufD; use serde::Deserialize; use serde_valid::Validate; use tera::Context; @@ -32,6 +34,7 @@ pub fn routes() -> Router { get_service(tower_http::services::ServeDir::new("./public")), ) .fallback(not_found_request) + .route("/docs/{name}", get(view_doc_request)) // pages .route("/", get(index_request)) .route("/{slug}", get(view_request)) @@ -78,6 +81,39 @@ async fn index_request(Extension(data): Extension) -> impl IntoResponse { ) } +async fn view_doc_request( + Extension(data): Extension, + Path(name): Path, +) -> impl IntoResponse { + let (ref data, ref tera, ref build_code) = *data.read().await; + let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]); + + if !std::fs::exists(&path).unwrap_or(false) { + 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()); + } + + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) => { + let mut ctx = default_context(&data, &build_code); + ctx.insert("error", &Error::MiscError(e.to_string()).to_string()); + return Html(tera.render("error.lisp", &ctx).unwrap()); + } + }; + + let mut ctx = default_context(&data, &build_code); + + ctx.insert("text", &text); + ctx.insert("file_name", &name); + + return Html(tera.render("doc.lisp", &ctx).unwrap()); +} + async fn view_request( Extension(data): Extension, Path(slug): Path, @@ -258,27 +294,46 @@ fn default_random() -> String { salt() } +/// The time that must be waited between each entry creation. +const CREATE_WAIT_TIME: usize = 5000; + async fn create_request( + jar: CookieJar, Extension(data): Extension, Json(req): Json, -) -> impl IntoResponse { +) -> std::result::Result>> { let (ref data, _, _) = *data.read().await; + // check wait time + if let Some(cookie) = jar.get("__Secure-Claim-Next") { + if unix_epoch_timestamp() + != cookie + .to_string() + .replace("__Secure-Claim-Next=", "") + .parse::() + .unwrap_or(0) + { + return Err(Json( + Error::MiscError("You must wait a bit to create another entry".to_string()).into(), + )); + } + } + // check lengths if req.slug.len() < 2 { - return Json(Error::DataTooShort("slug".to_string()).into()); + return Err(Json(Error::DataTooShort("slug".to_string()).into())); } if req.slug.len() > 32 { - return Json(Error::DataTooLong("slug".to_string()).into()); + return Err(Json(Error::DataTooLong("slug".to_string()).into())); } if req.content.len() < 2 { - return Json(Error::DataTooShort("content".to_string()).into()); + return Err(Json(Error::DataTooShort("content".to_string()).into())); } if req.content.len() > 150_000 { - return Json(Error::DataTooLong("content".to_string()).into()); + return Err(Json(Error::DataTooLong("content".to_string()).into())); } // check slug @@ -288,17 +343,19 @@ async fn create_request( .unwrap(); if regex.captures(&req.slug).is_some() { - return Json(Error::MiscError("This slug contains invalid characters".to_string()).into()); + return Err(Json( + Error::MiscError("This slug contains invalid characters".to_string()).into(), + )); } // 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()), + Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())), }; if let Err(e) = metadata.validate() { - return Json(Error::MiscError(e.to_string()).into()); + return Err(Json(Error::MiscError(e.to_string()).into())); } // check for existing @@ -310,7 +367,9 @@ async fn create_request( .await .is_ok() { - return Json(Error::MiscError("Slug already in use".to_string()).into()); + return Err(Json( + Error::MiscError("Slug already in use".to_string()).into(), + )); } // create @@ -333,22 +392,31 @@ async fn create_request( ) .await { - return Json(e.into()); + return Err(Json(e.into())); } if let Err(e) = data .insert(format!("entries.views('{}')", req.slug), 0.to_string()) .await { - return Json(e.into()); + return Err(Json(e.into())); } // return - Json(ApiReturn { - ok: true, - message: "Success".to_string(), - payload: Some((req.slug, req.edit_code)), - }) + Ok(( + [( + "Set-Cookie", + format!( + "__Secure-Claim-Next={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=5", + unix_epoch_timestamp() + CREATE_WAIT_TIME + ), + )], + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some((req.slug, req.edit_code)), + }), + )) } #[derive(Deserialize)]