From d67e7c9c3342cbfa6c7960aa964e92dd5b223daa Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 13:35:23 -0400 Subject: [PATCH] add: littleweb full --- Cargo.lock | 9 +- crates/app/Cargo.toml | 9 +- crates/app/src/assets.rs | 13 + crates/app/src/langs/en-US.toml | 17 +- crates/app/src/main.rs | 2 +- crates/app/src/public/html/body.lisp | 36 ++ crates/app/src/public/html/components.lisp | 10 + .../src/public/html/littleweb/browser.lisp | 211 +++++++++++ .../app/src/public/html/littleweb/domain.lisp | 274 ++++++++++++++ .../src/public/html/littleweb/domains.lisp | 124 +++++++ .../src/public/html/littleweb/service.lisp | 347 ++++++++++++++++++ .../src/public/html/littleweb/services.lisp | 9 +- crates/app/src/public/html/macros.lisp | 2 +- crates/app/src/public/html/root.lisp | 2 + crates/app/src/public/js/atto.js | 5 +- crates/app/src/public/js/proto_links.js | 136 +++++++ crates/app/src/routes/api/v1/domains.rs | 36 +- crates/app/src/routes/api/v1/mod.rs | 19 +- crates/app/src/routes/api/v1/services.rs | 78 +++- crates/app/src/routes/assets.rs | 1 + crates/app/src/routes/mod.rs | 1 + crates/app/src/routes/pages/journals.rs | 2 +- crates/app/src/routes/pages/littleweb.rs | 211 +++++++++++ crates/app/src/routes/pages/mod.rs | 8 + crates/core/Cargo.toml | 8 +- crates/core/src/config.rs | 3 + crates/core/src/database/domains.rs | 40 +- crates/core/src/database/services.rs | 3 +- crates/core/src/model/littleweb.rs | 148 ++++++-- crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- crates/shared/src/markdown.rs | 2 + 32 files changed, 1699 insertions(+), 71 deletions(-) create mode 100644 crates/app/src/public/html/littleweb/browser.lisp create mode 100644 crates/app/src/public/html/littleweb/domain.lisp create mode 100644 crates/app/src/public/html/littleweb/domains.lisp create mode 100644 crates/app/src/public/html/littleweb/service.lisp create mode 100644 crates/app/src/public/js/proto_links.js create mode 100644 crates/app/src/routes/pages/littleweb.rs diff --git a/Cargo.lock b/Cargo.lock index f3f387d..c90535f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3249,7 +3249,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "10.0.0" +version = "11.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3280,7 +3280,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "10.0.0" +version = "11.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3289,6 +3289,7 @@ dependencies = [ "emojis", "md-5", "oiseau", + "paste", "pathbufd", "regex", "reqwest", @@ -3302,7 +3303,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "10.0.0" +version = "11.0.0" dependencies = [ "pathbufd", "serde", @@ -3311,7 +3312,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "10.0.0" +version = "11.0.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 8a775e8..8a7eb6e 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "10.0.0" +version = "11.0.0" edition = "2024" [dependencies] @@ -9,7 +9,12 @@ serde = { version = "1.0.219", features = ["derive"] } tera = "1.20.0" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic", "set-header"] } +tower-http = { version = "0.6.6", features = [ + "trace", + "fs", + "catch-panic", + "set-header", +] } axum = { version = "0.8.4", features = ["macros", "ws"] } tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 1bc09ad..81671fe 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -41,6 +41,7 @@ pub const ME_JS: &str = include_str!("./public/js/me.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); pub const CARP_JS: &str = include_str!("./public/js/carp.js"); pub const LAYOUT_EDITOR_JS: &str = include_str!("./public/js/layout_editor.js"); +pub const PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.js"); // html pub const BODY: &str = include_str!("./public/html/body.lisp"); @@ -133,6 +134,12 @@ pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp pub const JOURNALS_APP: &str = include_str!("./public/html/journals/app.lisp"); +pub const LITTLEWEB_SERVICES: &str = include_str!("./public/html/littleweb/services.lisp"); +pub const LITTLEWEB_DOMAINS: &str = include_str!("./public/html/littleweb/domains.lisp"); +pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/service.lisp"); +pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp"); +pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -428,6 +435,12 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"journals/app.html"(crate::assets::JOURNALS_APP) -d "journals" --config=config --lisp plugins); + write_template!(html_path->"littleweb/services.html"(crate::assets::LITTLEWEB_SERVICES) -d "littleweb" --config=config --lisp plugins); + write_template!(html_path->"littleweb/domains.html"(crate::assets::LITTLEWEB_DOMAINS) --config=config --lisp plugins); + write_template!(html_path->"littleweb/service.html"(crate::assets::LITTLEWEB_SERVICE) --config=config --lisp plugins); + write_template!(html_path->"littleweb/domain.html"(crate::assets::LITTLEWEB_DOMAIN) --config=config --lisp plugins); + write_template!(html_path->"littleweb/browser.html"(crate::assets::LITTLEWEB_BROWSER) --config=config --lisp plugins); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index cfae86e..a852d5a 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -18,6 +18,7 @@ version = "1.0.0" "general:link.search" = "Search" "general:link.journals" = "Journals" "general:link.achievements" = "Achievements" +"general:link.little_web" = "Little web" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" @@ -29,6 +30,7 @@ version = "1.0.0" "general:action.open" = "Open" "general:action.view" = "View" "general:action.copy_link" = "Copy link" +"general:action.copy_id" = "Copy ID" "general:action.post" = "Post" "general:label.account" = "Account" "general:label.safety" = "Safety" @@ -269,4 +271,17 @@ version = "1.0.0" "journals:action.view" = "View" "littleweb:label.create_new" = "Create new site" -"littleweb:label.my_services" = "My services" +"littleweb:label.create_new_domain" = "Create new domain" +"littleweb:label.my_services" = "My sites" +"littleweb:label.my_domains" = "My domains" +"littleweb:label.browser" = "Browser" +"littleweb:label.tld" = "Top-level domain" +"littleweb:label.services" = "Sites" +"littleweb:label.domains" = "Domains" +"littleweb:label.domain_data" = "Domain data" +"littleweb:label.type" = "Type" +"littleweb:label.name" = "Name" +"littleweb:label.value" = "Value" +"littleweb:action.edit_site_name" = "Edit site name" +"littleweb:action.rename" = "Rename" +"littleweb:action.add" = "Add" diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index baad195..00ad85f 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -123,7 +123,7 @@ async fn main() { .merge(routes::routes(&config)) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors 'self'"), + HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; frame-ancestors 'self'"), )); } diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index afc41b4..0e7caf9 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -94,6 +94,8 @@ atto[\"hooks::spotify_time_text\"](); // spotify durations atto[\"hooks::verify_emoji\"](); + fix_atto_links(); + if (document.getElementById(\"tokens\")) { trigger(\"me::render_token_picker\", [ document.getElementById(\"tokens\"), @@ -163,6 +165,40 @@ (icon (text "x")) (str (text "dialog:action.cancel")))))) +(dialog + ("id" "littleweb") + (div + ("class" "inner flex flex-col gap-2") + + (a + ("class" "button w-full lowered justify-start") + ("href" "/net") + (icon (text "globe")) + (str (text "littleweb:label.browser"))) + + (a + ("class" "button w-full lowered justify-start") + ("href" "/services") + (icon (text "panel-top")) + (str (text "littleweb:label.my_services"))) + + (a + ("class" "button w-full lowered justify-start") + ("href" "/domains") + (icon (text "panel-top")) + (str (text "littleweb:label.my_domains"))) + + (hr ("class" "margin")) + (div + ("class" "flex gap-2 justify-between") + (div null?) + (button + ("class" "lowered red") + ("type" "button") + ("onclick", "document.getElementById('littleweb').close()") + (icon (text "x")) + (str (text "dialog:action.cancel")))))) + (dialog ("id" "web_api_prompt") (div diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 626efc0..156875e 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1112,6 +1112,12 @@ ("href" "/journals/0/0") (icon (text "notebook")) (str (text "general:link.journals"))) + (text "{% if config.lw_host -%}") + (button + ("onclick" "document.getElementById('littleweb').showModal()") + (icon (text "globe")) + (str (text "general:link.little_web"))) + (text "{%- endif %}") (text "{% if not user.settings.disable_achievements -%}") (a ("href" "/achievements") @@ -2333,6 +2339,10 @@ (text "Create infinite notes in each journal")) (li (text "Publish up to 50 notes")) + (li + (text "Create infinite Littleweb sites")) + (li + (text "Create infinite Littleweb domains")) (text "{% if config.security.enable_invite_codes -%}") (li diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp new file mode 100644 index 0000000..76ac82b --- /dev/null +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -0,0 +1,211 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "{{ config.name }}")) + +(text "{% endblock %} {% block body %}") +(div + ("id" "panel") + ("class" "flex flex-row gap-2") + (a + ("class" "button camo") + ("href" "/") + (icon (text "house"))) + + (button + ("class" "lowered") + ("onclick" "back()") + (icon (text "arrow-left"))) + + (button + ("class" "lowered") + ("onclick" "forward()") + (icon (text "arrow-right"))) + + (button + ("class" "lowered") + ("onclick" "reload()") + (icon (text "rotate-cw"))) + + (form + ("class" "w-full flex gap-1 flex-row") + ("onsubmit" "event.preventDefault(); littleweb_navigate(event.target.uri.getAttribute('true_value'))") + (input + ("type" "uri") + ("class" "w-full") + ("true_value" "{{ path }}") + ("name" "uri") + ("id" "uri")) + + (button ("class" "lowered small square") (icon (text "arrow-right")))) + + (text "{% if user -%}") + (div + ("class" "dropdown") + (button + ("class" "flex-row camo") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "gap: var(--pad-1) !important") + (text "{{ components::avatar(username=user.username, size=\"24px\") }}") + (icon_class (text "chevron-down") (text "dropdown-arrow"))) + + (text "{{ components::user_menu() }}")) + (text "{%- endif %}")) + +(iframe + ("id" "browser_iframe") + ("frameborder" "0") + ("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }} {%- endif %}")) + +(style + ("data-turbo-temporary" "true") + (text ":root { + --panel-height: 45px; + } + + html, + body { + padding: 0; + margin: 0; + overflow: hidden; + } + + #panel { + width: 100dvw; + height: var(--panel-height); + padding: var(--pad-2); + } + + #panel input { + border: none; + background: var(--color-lowered); + transition: background 0.15s; + } + + #panel input:focus { + background: var(--color-super-lowered); + } + + @media screen and (max-width: 900px) { + #panel input:focus { + position: fixed; + width: calc(100dvw - (62px + var(--pad-2) * 2)) !important; + left: var(--pad-2); + } + } + + #panel button:not(.inner *), + #panel a.button:not(.inner *), + #panel input { + --h: 28.2px; + height: var(--h); + min-height: var(--h); + max-height: var(--h); + font-size: 14px; + } + + #panel button:not(.inner *), + #panel a.button:not(.inner *) { + padding: var(--pad-1) var(--pad-2); + } + + iframe { + width: 100dvw; + height: calc(100dvh - var(--panel-height)); + }")) + +(script + (text "function littleweb_navigate(uri) { + if (!uri.includes(\".html\")) { + uri = `${uri}/index.html`; + } + + if (!uri.startsWith(\"atto://\")) { + uri = `atto://${uri}`; + } + + // ... + console.log(\"navigate\", uri); + document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}`; + } + + document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => { + console.log(\"web content loaded\"); + }); + + window.addEventListener(\"message\", (e) => { + if (typeof e.data !== \"string\") { + console.log(\"refuse message (bad type)\"); + return; + } + + const data = JSON.parse(e.data); + + if (!data.t) { + console.log(\"refuse message (not for tetratto)\"); + return; + } + + console.log(\"received message\"); + + if (data.event === \"change_url\") { + const uri = new URL(data.target).pathname.slice(\"/api/v1/net/\".length); + window.history.pushState(null, null, `/net/${uri.replace(\"atto://\", \"\")}`); + document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + } + }); + + function back() { + post_message({ t: true, event: \"back\" }); + } + + function forward() { + post_message({ t: true, event: \"forward\" }); + } + + function reload() { + post_message({ t: true, event: \"reload\" }); + } + + function post_message(data) { + const origin = new URL(document.getElementById(\"browser_iframe\").src).origin; + document.getElementById(\"browser_iframe\").contentWindow.postMessage(JSON.stringify(data), origin); + } + + // handle dropdowns + window.addEventListener(\"blur\", () => { + trigger(\"atto::hooks::dropdown.close\"); + }); + + // url bar focus + document.getElementById(\"uri\").addEventListener(\"input\", (e) => { + e.target.setAttribute(\"true_value\", e.target.value); + }); + + let is_focused = false; + + document.getElementById(\"uri\").addEventListener(\"mouseenter\", (e) => { + e.target.value = e.target.getAttribute(\"true_value\").replace(\"/index.html\", \"\"); + }); + + document.getElementById(\"uri\").addEventListener(\"mouseleave\", (e) => { + if (is_focused) { + return; + } + + e.target.value = e.target.getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]; + }); + + document.getElementById(\"uri\").addEventListener(\"focus\", (e) => { + e.target.value = e.target.getAttribute(\"true_value\").replace(\"/index.html\", \"\"); + is_focused = true; + }); + + document.getElementById(\"uri\").addEventListener(\"blur\", (e) => { + e.target.value = e.target.getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]; + is_focused = false; + }); + + document.getElementById(\"uri\").value = document.getElementById(\"uri\").getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/domain.lisp b/crates/app/src/public/html/littleweb/domain.lisp new file mode 100644 index 0000000..d8b01f1 --- /dev/null +++ b/crates/app/src/public/html/littleweb/domain.lisp @@ -0,0 +1,274 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My services - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{% if user -%}") + (div + ("class" "pillmenu") + (a ("href" "/services") (str (text "littleweb:label.services"))) + (a ("href" "/domains") ("class" "active") (str (text "littleweb:label.domains")))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "{{ domain.name }}.{{ domain.tld|lower }}"))) + (div + ("class" "flex flex-col gap-2 card") + (code + ("class" "w-content") + (a + ("href" "atto://{{ domain.name }}.{{ domain.tld|lower }}") + (text "atto://{{ domain.name }}.{{ domain.tld|lower }}"))) + (div + ("class" "flex gap-2 flex-wrap") + (button + ("class" "red lowered") + ("onclick" "delete_domain()") + (icon (text "trash")) + (str (text "general:action.delete")))))) + (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex flex-col gap-2") + (div + ("class" "flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "panel-top")) + (span + (str (text "littleweb:label.domain_data")))) + + (div + ("class" "flex gap-2") + (button + ("class" "small lowered") + ("title" "Help") + ("onclick" "document.getElementById('domain_help').classList.toggle('hidden')") + (icon (text "circle-question-mark"))) + + (button + ("class" "small") + ("onclick" "document.getElementById('add_data').classList.toggle('hidden')") + (icon (text "plus")) + (str (text "littleweb:action.add"))))) + + (div + ("class" "card w-full lowered flex flex-col gap-2 hidden no_p_margin") + ("id" "domain_help") + (p (text "To link your domain to a site, go to the site and press \"Copy ID\".")) + (p (text "After you have the site's ID, click \"Add\" on this page, then paste the ID into the \"value\" field.")) + (p (text "If you've ever managed a real domain's DNS, this should be familiar.")))) + (div + ("class" "card flex flex-col gap-2") + ; add data + (form + ("id" "add_data") + ("class" "card hidden w-full lowered flex flex-col gap-2") + ("onsubmit" "add_data_from_form(event)") + (div + ("class" "flex gap-2") + (div + ("class" "flex w-full flex-col gap-1") + (label + ("for" "name") + (str (text "littleweb:label.type"))) + (select + ("type" "text") + ("name" "type") + ("id" "type") + ("placeholder" "type") + ("required" "") + (option ("value" "Service") (text "Site ID")) + (option ("value" "Text") (text "Text")))) + (div + ("class" "flex w-full flex-col gap-1") + (label + ("for" "name") + (str (text "littleweb:label.name"))) + (input + ("type" "text") + ("name" "name") + ("id" "name") + ("placeholder" "name") + ("minlength" "1") + ("maxlength" "32")) + (span ("class" "fade") (text "Use \"@\" for root."))) + (div + ("class" "flex w-full flex-col gap-1") + (label + ("for" "value") + (str (text "littleweb:label.value"))) + (input + ("type" "text") + ("name" "value") + ("id" "value") + ("placeholder" "value") + ("required" "") + ("minlength" "2") + ("maxlength" "256")))) + (div + ("class" "flex w-full justify-between") + (div) + (button + (icon (text "check")) + (str (text "general:action.save"))))) + ; data + (table + (thead + (tr + (th (text "Name")) + (th (text "Type")) + (th (text "Value")) + (th (text "Actions")))) + + (tbody + (text "{% for item in domain.data -%}") + (tr + (td (text "{{ item[0] }}")) + (text "{% for k,v in item[1] -%}") + (td (text "{{ k }}")) + (td (text "{{ v }}")) + (text "{%- endfor %}") + (td + ("style" "overflow: auto") + (div + ("class" "dropdown") + (button + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "ellipsis"))) + (div + ("class" "inner") + (button + ("onclick" "rename_data('{{ item[0] }}')") + (icon (text "pencil")) + (str (text "littleweb:action.rename"))) + + (button + ("class" "red") + ("onclick" "remove_data('{{ item[0] }}')") + (icon (text "trash")) + (str (text "general:action.delete"))))))) + (text "{%- endfor %}")))))) + +(script ("id" "domain_data") ("type" "application/json") (text "{{ domain.data|json_encode()|safe }}")) +(script + (text "globalThis.DOMAIN_DATA = JSON.parse(document.getElementById(\"domain_data\").innerText); + async function save_data() { + await trigger(\"atto::debounce\", [\"domains::update_data\"]); + fetch(\"/api/v1/domains/{{ domain.id }}/data\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + data: DOMAIN_DATA, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function add_data_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"domains::add_data\"]); + + const x = {}; + x[e.target.type.selectedOptions[0].value] = e.target.value.value; + + if (e.target.name.value === \"\") { + e.target.name.value = \"@\"; + } + + const name = e.target.name.value.replace(\" \", \"_\"); + if (DOMAIN_DATA.find((x) => x[0] === name)) { + return; + } + + DOMAIN_DATA.push([name, x]); + await save_data(); + e.target.reset(); + } + + async function delete_data(name) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"domains::delete_data\"]); + + delete DOMAIN_DATA.find((x) => x[0] === name); + await save_data(); + } + + async function delete_domain() { + await trigger(\"atto::debounce\", [\"domains::delete\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/domains/{{ domain.id }}\", { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function rename_data(selector) { + await trigger(\"atto::debounce\", [\"domains::rename_data\"]); + + let name = await trigger(\"atto::prompt\", [\"New name:\"]); + + if (!name) { + return; + } + + DOMAIN_DATA.find((x) => x[0] === selector)[0] = name.replaceAll(\" \", \"_\"); + await save_data(); + + setTimeout(() => { + window.location.reload(); + }, 150); + } + + async function remove_data(name) { + await trigger(\"atto::debounce\", [\"domains::remove_data\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + let i = 0; + DOMAIN_DATA.find((x) => { + i += 1; + return x[0] === name; + }); + + DOMAIN_DATA.splice(i - 1, 1); + await save_data(); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/domains.lisp b/crates/app/src/public/html/littleweb/domains.lisp new file mode 100644 index 0000000..1a9b649 --- /dev/null +++ b/crates/app/src/public/html/littleweb/domains.lisp @@ -0,0 +1,124 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My domains - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{% if user -%}") + (div + ("class" "pillmenu") + (a ("href" "/services") (str (text "littleweb:label.services"))) + (a ("href" "/domains") ("class" "active") (str (text "littleweb:label.domains")))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (str (text "littleweb:label.create_new_domain")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "create_domain_from_form(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "name") + (text "{{ text \"communities:label.name\" }}")) + (input + ("type" "text") + ("name" "name") + ("id" "name") + ("placeholder" "name") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (div + ("class" "flex flex-col gap-1") + (label + ("for" "tld") + (str (text "littleweb:label.tld"))) + (select + ("type" "text") + ("name" "tld") + ("id" "tld") + ("placeholder" "tld") + ("required" "") + (text "{% for tld in tlds -%}") + (option ("value" "{{ tld }}") (text ".{{ tld|lower }}")) + (text "{%- endfor %}"))) + (button + ("class" "primary") + (text "{{ text \"communities:action.create\" }}")) + + (details + (summary + (icon (text "circle-alert")) + (text "Disclaimer")) + + (div + ("class" "card lowered no_p_margin") + (p (text "Domains are registered into {{ config.name }}'s closed web.")) + (p (text "This means that domains are only accessible through {{ config.name }}, as well as other supporting sites.")) + (p (text "If you would prefer a public-facing domain, those cost money and cannot be bought from {{ config.name }}.")) + (p (text "All domains have first-class support on {{ config.name }}, meaning all links to them will work properly on this site.")))))) + (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "panel-top")) + (span + (str (text "littleweb:label.my_domains"))))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for item in list %}") + (a + ("href" "/domains/{{ item.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "globe")) + (b + (text "{{ item.name }}.{{ item.tld|lower }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ item.created }}")) + (text "; {{ item.data|length }} entries"))) + (text "{% endfor %}")))) + +(script + (text "async function create_domain_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"domains::create\"]); + + fetch(\"/api/v1/domains\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name: e.target.name.value, + tld: e.target.tld.selectedOptions[0].value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/domains/${res.payload}`; + }, 100); + } + }); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/service.lisp b/crates/app/src/public/html/littleweb/service.lisp new file mode 100644 index 0000000..b0b7ac9 --- /dev/null +++ b/crates/app/src/public/html/littleweb/service.lisp @@ -0,0 +1,347 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My services - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{% if user -%}") + (div + ("class" "pillmenu") + (a ("href" "/services") ("class" "active") (str (text "littleweb:label.services"))) + (a ("href" "/domains") (str (text "littleweb:label.domains")))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "{{ service.name }}"))) + + (div + ("class" "flex gap-2 flex-wrap card") + (text "{% if file and file.children|length == 0 -%}") + (button + ("onclick" "update_content()") + (icon (text "check")) + (str (text "general:action.save"))) + (text "{%- endif %}") + + (button + ("class" "lowered") + ("onclick" "update_name()") + (icon (text "pencil")) + (str (text "littleweb:action.edit_site_name"))) + + (button + ("class" "lowered") + ("onclick" "trigger('atto::copy_text', ['{{ service.id }}'])") + (icon (text "copy")) + (str (text "general:action.copy_id"))) + + (button + ("class" "red lowered") + ("onclick" "delete_service()") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "folder-open")) + (span (text "{% if path -%} {{ path }} {%- else -%} / {%- endif %}"))) + + (div + ("class" "flex items-center gap-2") + (button + ("class" "lowered small") + ("onclick" "go_up()") + (icon (text "arrow-up"))) + + (text "{% if not file or file.content|length == 0 -%}") + (button + ("class" "lowered small") + ("onclick" "create_file()") + (icon (text "plus")) + (str (text "communities:action.create"))) + (text "{%- endif %}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% if not file or file.children|length > 0 -%}") + ; directory browser + (table + (thead + (tr + (th (text "Name")) + (th (text "Type")) + (th (text "Children")) + (th (text "Actions")))) + + (tbody + (text "{% for item in files %}") + (tr + (td + ("class" "flex gap-2 items-center") + (text "{% if item.children|length > 0 -%}") + (icon (text "folder")) + (text "{% else %}") + (icon (text "file")) + (text "{%- endif %}") + + (a + ("href" "?path={{ path }}/{{ item.name }}") + ("data-turbo" "false") + (text "{{ item.name }}"))) + (td (text "{{ item.mime }}")) + (td (text "{{ item.children|length }}")) + (td + ("style" "overflow: auto") + (div + ("class" "dropdown") + (button + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "ellipsis"))) + (div + ("class" "inner") + (button + ("onclick" "rename_file('{{ item.id }}')") + (icon (text "pencil")) + (str (text "littleweb:action.rename"))) + + (button + ("class" "red") + ("onclick" "remove_file('{{ item.id }}')") + (icon (text "trash")) + (str (text "general:action.delete"))))))) + (text "{% endfor %}"))) + (text "{% else %}") + ; file editor + (div ("id" "editor_container") ("class" "w-full") ("style" "height: 600px")) + (text "{%- endif %}")))) + +(script ("id" "all_service_files") ("type" "application/json") (text "{{ service.files|json_encode()|remove_script_tags|safe }}")) +(script ("id" "service_files") ("type" "application/json") (text "{{ files|json_encode()|remove_script_tags|safe }}")) +(script ("id" "id_path") ("type" "application/json") (text "{{ id_path|json_encode()|remove_script_tags|safe }}")) + +(script + (text "globalThis.SERVICE_FILES = JSON.parse(document.getElementById(\"service_files\").innerText); + globalThis.EXTENSION_MIMES = { + \"html\": \"text/html\", + \"js\": \"text/javascript\", + \"css\": \"text/css\", + \"json\": \"application/json\", + \"txt\": \"text/plain\", + } + + globalThis.MIME_MODES = { + \"Html\": \"html\", + \"Js\": \"javascript\", + \"Css\": \"css\", + \"Json\": \"json\", + \"Plain\": \"txt\", + } + + function go_up() { + const x = JSON.parse(document.getElementById(\"id_path\").innerText); + const y = JSON.parse(document.getElementById(\"all_service_files\").innerText); + + x.pop(); + let path = \"\"; + + for (id of x) { + path += `/${y.find((x) => x.id == id).name}`; + } + + window.location.href = `?path=${path}`; + } + + async function update_name() { + await trigger(\"atto::debounce\", [\"services::update_name\"]); + + const name = await trigger(\"atto::prompt\", [\"New name:\"]); + + if (!name) { + return; + } + + fetch(\"/api/v1/services/{{ service.id }}/name\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name: e.target.name.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function delete_service() { + await trigger(\"atto::debounce\", [\"services::delete\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/services/{{ service.id }}\", { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_content() { + await trigger(\"atto::debounce\", [\"services::update_content\"]); + const content = globalThis.editor.getValue(); + fetch(\"/api/v1/services/{{ service.id }}/content\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + content, + id_path: JSON.parse(document.getElementById(\"id_path\").innerText), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_files() { + await trigger(\"atto::debounce\", [\"services::update_files\"]); + fetch(\"/api/v1/services/{{ service.id }}/files\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + files: SERVICE_FILES, + id_path: JSON.parse(document.getElementById(\"id_path\").innerText), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function create_file() { + await trigger(\"atto::debounce\", [\"services::create_file\"]); + + let name = await trigger(\"atto::prompt\", [\"Name:\"]); + + if (!name) { + return; + } + + const s = name.split(\".\"); + SERVICE_FILES.push({ + id: window.crypto.randomUUID(), + name, + mime: EXTENSION_MIMES[s[s.length - 1]] || EXTENSION_MIMES[\"txt\"], + children: [], + content: \"\", + }); + + await update_files(); + + setTimeout(() => { + window.location.reload(); + }, 150); + } + + async function rename_file(id) { + await trigger(\"atto::debounce\", [\"services::rename_file\"]); + + let name = await trigger(\"atto::prompt\", [\"New name:\"]); + + if (!name) { + return; + } + + const file_ref = SERVICE_FILES.find((x) => x.id === id); + file_ref.name = name; + + const s = name.split(\".\"); + file_ref.mime = EXTENSION_MIMES[s[s.length - 1]] || EXTENSION_MIMES[\"txt\"]; + + await update_files(); + } + + async function remove_file(id) { + await trigger(\"atto::debounce\", [\"services::remove_file\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + let i = 0; + SERVICE_FILES.find((x) => { + i += 1; + return x.id === id; + }); + + SERVICE_FILES.splice(i - 1, 1); + await update_files(); + }")) + +(text "{% if file and file.mime != 'Plain' -%}") +(script ("src" "https://unpkg.com/monaco-editor@0.52.2/min/vs/loader.js")) +(script ("id" "file_content") ("type" "text/plain") (text "{{ file.content|remove_script_tags|safe }}")) +(script + (text "require.config({ paths: { vs: \"https://unpkg.com/monaco-editor@0.52.2/min/vs\" } }); + + require([\"vs/editor/editor.main\"], () => { + const shadow = document.getElementById(\"editor_container\").attachShadow({ + mode: \"closed\", + }); + + const inner = document.createElement(\"div\"); + inner.style.width = window.getComputedStyle(document.getElementById(\"editor_container\")).width; + inner.style.height = window.getComputedStyle(document.getElementById(\"editor_container\")).height; + shadow.appendChild(inner); + + const style = document.createElement(\"style\"); + style.innerText = '@import \"https://unpkg.com/monaco-editor@0.52.2/min/vs/editor/editor.main.css\";'; + shadow.appendChild(style); + + globalThis.editor = monaco.editor.create(inner, { + value: document.getElementById(\"file_content\").innerText.replaceAll(\"</script>\", \"\"), + language: MIME_MODES[\"{{ file.mime }}\"], + theme: \"vs-dark\", + }); + });")) +(text "{%- endif %}") +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index e4525ca..cca5af7 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -1,11 +1,16 @@ (text "{% extends \"root.html\" %} {% block head %}") (title - (text "My stacks - {{ config.name }}")) + (text "My services - {{ config.name }}")) (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex-col gap-2") - (text "{{ macros::timelines_nav(selected=\"littleweb\") }} {% if user -%}") + (text "{% if user -%}") + (div + ("class" "pillmenu") + (a ("href" "/services") ("class" "active") (str (text "littleweb:label.services"))) + (a ("href" "/domains") (str (text "littleweb:label.domains")))) + (div ("class" "card-nest") (div diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index a554351..f9d8a1f 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -71,7 +71,7 @@ (button ("class" "flex-row title") ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exlude" "dropdown") + ("exclude" "dropdown") ("style" "gap: var(--pad-1) !important") (text "{{ components::avatar(username=user.username, size=\"24px\") }}") (icon_class (text "chevron-down") (text "dropdown-arrow"))) diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 93312bb..6730dd8 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -35,10 +35,12 @@ globalThis.no_policy = false; globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\"; + globalThis.TETRATTO_LINK_HANDLER_CTX = \"net\"; ") (script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" )) (script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" )) + (script ("defer" "true") ("src" "/js/proto_links.js?v=tetratto-{{ random_cache_breaker }}" )) (meta ("name" "theme-color") ("content" "{{ config.color }}")) (meta ("name" "description") ("content" "{{ config.description }}")) diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 43a46b8..1b2a4db 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -855,7 +855,8 @@ media_theme_pref(); anchor.href.startsWith("https://tetratto.com") || anchor.href.startsWith("https://buy.stripe.com") || anchor.href.startsWith("https://billing.stripe.com") || - anchor.href.startsWith("https://last.fm") + anchor.href.startsWith("https://last.fm") || + anchor.href.startsWith("atto://") ) { continue; } @@ -1333,6 +1334,8 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} atto["hooks::online_indicator"](); atto["hooks::verify_emoji"](); atto["hooks::check_reactions"](); + + fix_atto_links(); }); })(); diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js new file mode 100644 index 0000000..ab5d938 --- /dev/null +++ b/crates/app/src/public/js/proto_links.js @@ -0,0 +1,136 @@ +if (!globalThis.TETRATTO_LINK_HANDLER_CTX) { + globalThis.TETRATTO_LINK_HANDLER_CTX = "embed"; +} + +// create little link preview box +function create_link_preview() { + globalThis.TETRATTO_LINK_PREVIEW = document.createElement("div"); + globalThis.TETRATTO_LINK_PREVIEW.style.position = "fixed"; + globalThis.TETRATTO_LINK_PREVIEW.style.bottom = "0.5rem"; + globalThis.TETRATTO_LINK_PREVIEW.style.left = "0.5rem"; + globalThis.TETRATTO_LINK_PREVIEW.style.background = "#323232"; + globalThis.TETRATTO_LINK_PREVIEW.style.color = "#ffffff"; + globalThis.TETRATTO_LINK_PREVIEW.style.borderRadius = "4px"; + globalThis.TETRATTO_LINK_PREVIEW.style.padding = "0.25rem 0.5rem"; + globalThis.TETRATTO_LINK_PREVIEW.style.display = "none"; + globalThis.TETRATTO_LINK_PREVIEW.id = "tetratto_link_preview"; + globalThis.TETRATTO_LINK_PREVIEW.setAttribute( + "data-turbo-permanent", + "true", + ); + document.body.appendChild(globalThis.TETRATTO_LINK_PREVIEW); +} + +/// Clean up all "atto://" links on the page. +function fix_atto_links() { + setTimeout(() => { + if (!document.getElementById("tetratto_link_preview")) { + create_link_preview(); + } + }, 500); + + if (TETRATTO_LINK_HANDLER_CTX === "embed") { + // relative links for embeds + const path = window.location.pathname.slice("/api/v1/net/".length); + + function fix_element( + selector = "a", + property = "href", + relative = true, + ) { + for (const y of Array.from(document.querySelectorAll(selector))) { + if (!y[property].startsWith(window.location.origin)) { + continue; + } + + let x = new URL(y[property]).pathname; + + if (!x.includes(".html")) { + x = `${x}/index.html`; + } + + if (relative) { + y[property] = + `atto://${path.replace("atto://", "").split("/")[0]}${x}`; + } else { + y[property] = + `/api/v1/net/atto://${path.replace("atto://", "").split("/")[0]}${x}`; + } + } + } + + fix_element("a", "href", true); + fix_element("link", "href", false); + fix_element("script", "src", false); + + // send message + window.top.postMessage( + JSON.stringify({ + t: true, + event: "change_url", + target: window.location.href, + }), + "*", + ); + + // handle messages + window.addEventListener("message", (e) => { + if (typeof e.data !== "string") { + console.log("refuse message (bad type)"); + return; + } + + const data = JSON.parse(e.data); + + if (!data.t) { + console.log("refuse message (not for tetratto)"); + return; + } + + console.log("received message"); + + if (data.event === "back") { + window.history.back(); + } else if (data.event === "forward") { + window.history.forward(); + } else if (data.event === "reload") { + window.location.reload(); + } + }); + } + + for (const anchor of Array.from(document.querySelectorAll("a"))) { + if ( + !anchor.href.startsWith("atto://") || + anchor.getAttribute("data-checked") === "true" + ) { + continue; + } + + const href = structuredClone(anchor.href); + + anchor.addEventListener("click", () => { + if (TETRATTO_LINK_HANDLER_CTX === "net") { + window.location.href = `/net/${href.replace("atto://", "")}`; + } else { + window.location.href = `/api/v1/net/${href}`; + } + }); + + anchor.addEventListener("mouseenter", () => { + TETRATTO_LINK_PREVIEW.innerText = href; + TETRATTO_LINK_PREVIEW.style.display = "block"; + }); + + anchor.addEventListener("mouseleave", () => { + TETRATTO_LINK_PREVIEW.style.display = "none"; + }); + + anchor.removeAttribute("href"); + anchor.style.cursor = "pointer"; + anchor.setAttribute("data-checked", "true"); + } +} + +fix_atto_links(); +create_link_preview(); diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index aec1a01..8cfd9dc 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -3,15 +3,12 @@ use crate::{ routes::api::v1::{CreateDomain, UpdateDomainData}, State, }; -use axum::{ - extract::{Path, Query}, - response::IntoResponse, - http::StatusCode, - Extension, Json, -}; +use axum::{extract::Path, response::IntoResponse, http::StatusCode, Extension, Json}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{littleweb::Domain, oauth, ApiReturn, Error}; -use serde::Deserialize; +use tetratto_core::model::{ + littleweb::{Domain, ServiceFsMime}, + oauth, ApiReturn, Error, +}; pub async fn get_request( Path(id): Path, @@ -112,17 +109,12 @@ pub async fn delete_request( } } -#[derive(Deserialize)] -pub struct GetFileQuery { - pub addr: String, -} - pub async fn get_file_request( + Path(addr): Path, Extension(data): Extension, - Query(props): Query, ) -> impl IntoResponse { let data = &(data.read().await).0; - let (subdomain, domain, tld, path) = Domain::from_str(&props.addr); + let (subdomain, domain, tld, path) = Domain::from_str(&addr); // resolve domain let domain = match data.get_domain_by_name_tld(&domain, &tld).await { @@ -150,9 +142,19 @@ pub async fn get_file_request( // resolve file match service.file(&path) { - Some(f) => Ok(( + Some((f, _)) => Ok(( [("Content-Type".to_string(), f.mime.to_string())], - f.content, + if f.mime == ServiceFsMime::Html { + f.content.replace( + "", + &format!( + "", + data.0.0.host + ), + ) + } else { + f.content + }, )), None => { return Err(( diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 59f4353..506e74f 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -641,7 +641,12 @@ pub fn routes() -> Router { .route("/services", post(services::create_request)) .route("/services/{id}", get(services::get_request)) .route("/services/{id}", delete(services::delete_request)) + .route("/services/{id}/name", post(services::update_name_request)) .route("/services/{id}/files", post(services::update_files_request)) + .route( + "/services/{id}/content", + post(services::update_content_request), + ) // domains .route("/domains", get(domains::list_request)) .route("/domains", post(domains::create_request)) @@ -651,7 +656,7 @@ pub fn routes() -> Router { } pub fn lw_routes() -> Router { - Router::new().route("/file", get(domains::get_file_request)) + Router::new().route("/net/{*addr}", get(domains::get_file_request)) } #[derive(Deserialize)] @@ -1076,9 +1081,21 @@ pub struct CreateService { pub name: String, } +#[derive(Deserialize)] +pub struct UpdateServiceName { + pub name: String, +} + #[derive(Deserialize)] pub struct UpdateServiceFiles { pub files: Vec, + pub id_path: Vec, +} + +#[derive(Deserialize)] +pub struct UpdateServiceFileContent { + pub content: String, + pub id_path: Vec, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs index 36895d6..252fe5a 100644 --- a/crates/app/src/routes/api/v1/services.rs +++ b/crates/app/src/routes/api/v1/services.rs @@ -1,6 +1,8 @@ use crate::{ get_user_from_token, - routes::api::v1::{UpdateServiceFiles, CreateService}, + routes::api::v1::{ + CreateService, UpdateServiceFileContent, UpdateServiceFiles, UpdateServiceName, + }, State, }; use axum::{extract::Path, response::IntoResponse, Extension, Json}; @@ -60,6 +62,28 @@ pub async fn create_request( } } +pub async fn update_name_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_service_name(id, &user, &req.name).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn update_files_request( jar: CookieJar, Extension(data): Extension, @@ -72,7 +96,57 @@ pub async fn update_files_request( None => return Json(Error::NotAllowed.into()), }; - match data.update_service_files(id, &user, req.files).await { + let mut service = match data.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if req.id_path.is_empty() { + service.files = req.files; + } else { + match service.file_mut(req.id_path) { + Some(f) => f.children = req.files, + None => return Json(Error::GeneralNotFound("file".to_string()).into()), + } + } + + match data.update_service_files(id, &user, service.files).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_content_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut service = match data.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // update + let file = match service.file_mut(req.id_path) { + Some(f) => f, + None => return Json(Error::GeneralNotFound("file".to_string()).into()), + }; + + file.content = req.content; + + // ... + match data.update_service_files(id, &user, service.files).await { Ok(_) => Json(ApiReturn { ok: true, message: "Service updated".to_string(), diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index 2e66c19..2aa1bc5 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -20,3 +20,4 @@ serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); serve_asset!(carp_js_request: CARP_JS("text/javascript")); serve_asset!(layout_editor_js_request: LAYOUT_EDITOR_JS("text/javascript")); +serve_asset!(proto_links_request: PROTO_LINKS_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 81746d9..0872632 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -24,6 +24,7 @@ pub fn routes(config: &Config) -> Router { "/js/layout_editor.js", get(assets::layout_editor_js_request), ) + .route("/js/proto_links.js", get(assets::proto_links_request)) .nest_service( "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index ca48e78..cdfba32 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -365,7 +365,7 @@ pub async fn global_view_request( Ok(( [( "content-security-policy", - "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors *", + "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; frame-ancestors *", )], Html(data.1.render("journals/app.html", &context).unwrap()), )) diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs new file mode 100644 index 0000000..9dc5907 --- /dev/null +++ b/crates/app/src/routes/pages/littleweb.rs @@ -0,0 +1,211 @@ +use super::render_error; +use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use axum::{ + response::{Html, IntoResponse}, + extract::{Query, Path}, + Extension, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{littleweb::TLDS_VEC, Error}; +use serde::Deserialize; + +/// `/services` +pub async fn services_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let list = match data.0.get_services_by_user(user.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("list", &list); + + // return + Ok(Html( + data.1.render("littleweb/services.html", &context).unwrap(), + )) +} + +/// `/domains` +pub async fn domains_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let list = match data.0.get_domains_by_user(user.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert("list", &list); + context.insert("tlds", &*TLDS_VEC); + + // return + Ok(Html( + data.1.render("littleweb/domains.html", &context).unwrap(), + )) +} + +#[derive(Deserialize)] +pub struct FileBrowserProps { + #[serde(default)] + path: String, +} + +/// `/services/{id}` +pub async fn service_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let service = match data.0.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if user.id != service.owner { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert("service", &service); + + match service.file(&props.path.replacen("/", "", 1)) { + Some((x, p)) => { + context.insert("id_path", &p); + context.insert("file", &x); + context.insert("files", &x.children); + } + None => { + context.insert("id_path", &Vec::<()>::new()); + context.insert("files", &service.files); + } + } + + let path_segments: Vec<&str> = props.path.split("/").collect(); + context.insert("path_segments", &path_segments); + context.insert("path", &props.path); + + // return + Ok(Html( + data.1.render("littleweb/service.html", &context).unwrap(), + )) +} + +/// `/domains/{id}` +pub async fn domain_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let domain = match data.0.get_domain_by_id(id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if user.id != domain.owner { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("domain", &domain); + + // return + Ok(Html( + data.1.render("littleweb/domain.html", &context).unwrap(), + )) +} + +/// `/net` +pub async fn browser_home_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + context.insert("path", &""); + + // return + Html(data.1.render("littleweb/browser.html", &context).unwrap()) +} + +/// `/net/{uri}` +pub async fn browser_request( + jar: CookieJar, + Path(mut uri): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + if !uri.contains("/") { + uri = format!("{uri}/index.html"); + } + + if !uri.starts_with("atto://") { + uri = format!("atto://{uri}"); + } + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + context.insert("path", &uri); + + // return + Html(data.1.render("littleweb/browser.html", &context).unwrap()) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 07bd5a7..6ce6318 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -4,6 +4,7 @@ pub mod communities; pub mod developer; pub mod forge; pub mod journals; +pub mod littleweb; pub mod misc; pub mod mod_panel; pub mod profile; @@ -139,6 +140,13 @@ pub fn routes() -> Router { .route("/@{owner}/{journal}", get(journals::index_view_request)) .route("/@{owner}/{journal}/{note}", get(journals::view_request)) .route("/x/{note}", get(journals::global_view_request)) + // littleweb + .route("/services", get(littleweb::services_request)) + .route("/domains", get(littleweb::domains_request)) + .route("/services/{id}", get(littleweb::service_request)) + .route("/domains/{id}", get(littleweb::domain_request)) + .route("/net", get(littleweb::browser_home_request)) + .route("/net/{*uri}", get(littleweb::browser_request)) } pub fn lw_routes() -> Router { diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e47db7a..ffbd2c2 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "10.0.0" +version = "11.0.0" edition = "2024" [dependencies] @@ -19,4 +19,8 @@ base16ct = { version = "0.2.0", features = ["alloc"] } base64 = "0.22.1" emojis = "0.7.0" regex = "1.11.1" -oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis"] } +oiseau = { version = "0.1.2", default-features = false, features = [ + "postgres", + "redis", +] } +paste = "1.0.15" diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 85ff839..309c851 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -361,6 +361,9 @@ fn default_banned_usernames() -> Vec { "search".to_string(), "journals".to_string(), "links".to_string(), + "app".to_string(), + "services".to_string(), + "domains".to_string(), ] } diff --git a/crates/core/src/database/domains.rs b/crates/core/src/database/domains.rs index 0249f6f..672de1c 100644 --- a/crates/core/src/database/domains.rs +++ b/crates/core/src/database/domains.rs @@ -1,8 +1,11 @@ -use crate::model::{ - auth::User, - littleweb::{Domain, DomainData, DomainTld}, - permissions::{FinePermission, SecondaryPermission}, - Error, Result, +use crate::{ + database::NAME_REGEX, + model::{ + auth::User, + littleweb::{Domain, DomainData, DomainTld}, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, + }, }; use crate::{auto_method, DataManager}; use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow}; @@ -71,6 +74,8 @@ impl DataManager { Ok(res.unwrap()) } + const MAXIMUM_FREE_DOMAINS: usize = 5; + /// Create a new domain in the database. /// /// # Arguments @@ -83,6 +88,31 @@ impl DataManager { return Err(Error::DataTooLong("name".to_string())); } + // check number of domains + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let domains = self.get_domains_by_user(data.owner).await?; + + if domains.len() >= Self::MAXIMUM_FREE_DOMAINS { + return Err(Error::MiscError( + "You already have the maximum number of domains you can have".to_string(), + )); + } + } + + // check name + let regex = regex::RegexBuilder::new(NAME_REGEX) + .multi_line(true) + .build() + .unwrap(); + + if regex.captures(&data.name).is_some() { + return Err(Error::MiscError( + "Domain name contains invalid characters".to_string(), + )); + } + // check for existing if self .get_domain_by_name_tld(&data.name, &data.tld) diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs index de67f74..adadf7e 100644 --- a/crates/core/src/database/services.rs +++ b/crates/core/src/database/services.rs @@ -126,5 +126,6 @@ impl DataManager { Ok(()) } - auto_method!(update_service_files(Vec)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); + auto_method!(update_service_name(&str)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}"); + auto_method!(update_service_files(Vec)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET files = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); } diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 79cffb1..479a444 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -1,6 +1,8 @@ use std::fmt::Display; use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; +use paste::paste; +use std::sync::LazyLock; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Service { @@ -24,18 +26,24 @@ impl Service { } /// Resolve a file from the virtual file system. - pub fn file(&self, path: &str) -> Option { + /// + /// # Returns + /// `(file, id path)` + pub fn file(&self, path: &str) -> Option<(ServiceFsEntry, Vec)> { let segments = path.chars().filter(|x| x == &'/').count(); let mut path = path.split("/"); let mut path_segment = path.next().unwrap(); + let mut ids = Vec::new(); let mut i = 0; let mut f = &self.files; while let Some(nf) = f.iter().find(|x| x.name == path_segment) { - if i == segments - 1 { - return Some(nf.to_owned()); + ids.push(nf.id.clone()); + + if i == segments { + return Some((nf.to_owned(), ids)); } f = &nf.children; @@ -45,6 +53,31 @@ impl Service { None } + + /// Resolve a file from the virtual file system (mutable). + /// + /// # Returns + /// `&mut file` + pub fn file_mut(&mut self, id_path: Vec) -> Option<&mut ServiceFsEntry> { + let total_segments = id_path.len(); + let mut i = 0; + + let mut f = &mut self.files; + for segment in id_path { + if let Some(nf) = f.iter_mut().find(|x| (**x).id == segment) { + if i == total_segments - 1 { + return Some(nf); + } + + f = &mut nf.children; + i += 1; + } else { + break; + } + } + + None + } } /// A file type for [`ServiceFsEntry`] structs. @@ -77,36 +110,92 @@ impl Display for ServiceFsMime { /// A single entry in the file system of [`Service`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServiceFsEntry { + /// Files use a UUID since they're generated on the client. + pub id: String, pub name: String, pub mime: ServiceFsMime, pub children: Vec, pub content: String, - /// SHA-256 checksum of the entry's content. - pub checksum: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum DomainTld { - Bunny, -} - -impl Display for DomainTld { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Bunny => "bunny", - }) - } -} - -impl From<&str> for DomainTld { - fn from(value: &str) -> Self { - match value { - "bunny" => Self::Bunny, - _ => Self::Bunny, +macro_rules! domain_tld_display_match { + ($self:ident, $($tld:ident),+ $(,)?) => { + match $self { + $( + Self::$tld => stringify!($tld).to_lowercase(), + )+ } } } +macro_rules! domain_tld_strings { + ($($tld:ident),+ $(,)?) => { + $( + paste! { + /// Constant from macro. + const []: LazyLock = LazyLock::new(|| stringify!($tld).to_lowercase()); + } + )+ + } +} + +macro_rules! domain_tld_from_match { + ($value:ident, $($tld:ident),+ $(,)?) => { + { + $( + paste! { + let [<$tld:snake:lower>] = &*[]; + } + )+; + + // can't use match here, the expansion is going to look really ugly + $( + if $value == paste!{ [<$tld:snake:lower>] } { + return Self::$tld; + } + )+ + + return Self::Bunny; + } + } +} + +macro_rules! define_domain_tlds { + ($($tld:ident),+ $(,)?) => { + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + pub enum DomainTld { + $($tld),+ + } + + domain_tld_strings!($($tld),+); + + impl From<&str> for DomainTld { + fn from(value: &str) -> Self { + domain_tld_from_match!( + value, $($tld),+ + ) + } + } + + impl Display for DomainTld { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // using this macro allows us to just copy and paste the enum variants + f.write_str(&domain_tld_display_match!( + self, $($tld),+ + )) + } + } + + /// This is VERY important so that I don't have to manually type them all for the UI dropdown. + pub const TLDS_VEC: LazyLock> = LazyLock::new(|| vec![$(stringify!($tld)),+]); + } +} + +define_domain_tlds!( + Bunny, Tet, Cool, Qwerty, Boy, Girl, Them, Quack, Bark, Meow, Silly, Wow, Neko, Yay, Lol, Love, + Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site +); + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Domain { pub id: usize, @@ -142,12 +231,12 @@ impl Domain { // we're reversing this so it's predictable, as there might not always be a subdomain // (we shouldn't have the variable entry be first, there is always going to be a tld) - let mut s: Vec<&str> = no_protocol.split(".").collect(); + let mut s: Vec<&str> = no_protocol.split("/").next().unwrap().split(".").collect(); s.reverse(); let mut s = s.into_iter(); let tld = DomainTld::from(s.next().unwrap()); - let domain = s.next().unwrap(); + let domain = s.next().unwrap_or("default.bunny"); let subdomain = s.next().unwrap_or("@"); // get path @@ -157,7 +246,7 @@ impl Domain { while char != '/' { // we need to keep eating characters until we reach the first / // (marking the start of the path) - char = chars.next().unwrap(); + char = chars.next().unwrap_or('/'); } let path: String = chars.collect(); @@ -183,7 +272,10 @@ impl Domain { pub fn service(&self, subdomain: &str) -> Option { let s = self.data.iter().find(|x| x.0 == subdomain)?; match s.1 { - DomainData::Service(id) => Some(id), + DomainData::Service(ref id) => Some(match id.parse::() { + Ok(id) => id, + Err(_) => return None, + }), _ => None, } } @@ -193,7 +285,7 @@ impl Domain { pub enum DomainData { /// The ID of the service this domain points to. The first service found will /// always be used. This means having multiple service entires will be useless. - Service(usize), + Service(String), /// A text entry with a maximum of 512 characters. Text(String), } diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 1b3e5e1..9544981 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "10.0.0" +version = "11.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 5fd4230..633984b 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "10.0.0" +version = "11.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/src/markdown.rs b/crates/shared/src/markdown.rs index 1ae665b..82d6b79 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -8,6 +8,7 @@ pub fn render_markdown(input: &str) -> String { compile: CompileOptions { allow_any_img_src: false, allow_dangerous_html: true, + allow_dangerous_protocol: true, gfm_task_list_item_checkable: false, gfm_tagfilter: false, ..Default::default() @@ -48,6 +49,7 @@ pub fn render_markdown(input: &str) -> String { ]) .rm_tags(&["script", "style", "link", "canvas"]) .add_tag_attributes("a", &["href", "target"]) + .add_url_schemes(&["atto"]) .clean(&html) .to_string() .replace(