add: littleweb full
This commit is contained in:
parent
3fc0872867
commit
d67e7c9c33
32 changed files with 1699 additions and 71 deletions
9
Cargo.lock
generated
9
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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'"),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
211
crates/app/src/public/html/littleweb/browser.lisp
Normal file
211
crates/app/src/public/html/littleweb/browser.lisp
Normal file
|
@ -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 %}")
|
274
crates/app/src/public/html/littleweb/domain.lisp
Normal file
274
crates/app/src/public/html/littleweb/domain.lisp
Normal file
|
@ -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 %}")
|
124
crates/app/src/public/html/littleweb/domains.lisp
Normal file
124
crates/app/src/public/html/littleweb/domains.lisp
Normal file
|
@ -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 %}")
|
347
crates/app/src/public/html/littleweb/service.lisp
Normal file
347
crates/app/src/public/html/littleweb/service.lisp
Normal file
|
@ -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>\", \"</script\" + \">\"),
|
||||
language: MIME_MODES[\"{{ file.mime }}\"],
|
||||
theme: \"vs-dark\",
|
||||
});
|
||||
});"))
|
||||
(text "{%- endif %}")
|
||||
(text "{% endblock %}")
|
|
@ -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
|
||||
|
|
|
@ -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")))
|
||||
|
|
|
@ -35,10 +35,12 @@
|
|||
|
||||
globalThis.no_policy = false;
|
||||
globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\";
|
||||
globalThis.TETRATTO_LINK_HANDLER_CTX = \"net\";
|
||||
</script>")
|
||||
|
||||
(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 }}"))
|
||||
|
|
|
@ -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}</textarea>` : ""}
|
|||
atto["hooks::online_indicator"]();
|
||||
atto["hooks::verify_emoji"]();
|
||||
atto["hooks::check_reactions"]();
|
||||
|
||||
fix_atto_links();
|
||||
});
|
||||
})();
|
||||
|
||||
|
|
136
crates/app/src/public/js/proto_links.js
Normal file
136
crates/app/src/public/js/proto_links.js
Normal file
|
@ -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();
|
|
@ -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<usize>,
|
||||
|
@ -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<String>,
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<GetFileQuery>,
|
||||
) -> 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(
|
||||
"</body>",
|
||||
&format!(
|
||||
"<script src=\"{}/js/proto_links.js\" defer></script></body>",
|
||||
data.0.0.host
|
||||
),
|
||||
)
|
||||
} else {
|
||||
f.content
|
||||
},
|
||||
)),
|
||||
None => {
|
||||
return Err((
|
||||
|
|
|
@ -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<ServiceFsEntry>,
|
||||
pub id_path: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateServiceFileContent {
|
||||
pub content: String,
|
||||
pub id_path: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
|
@ -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<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateServiceName>,
|
||||
) -> 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<State>,
|
||||
|
@ -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<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateServiceFileContent>,
|
||||
) -> 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(),
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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()),
|
||||
))
|
||||
|
|
211
crates/app/src/routes/pages/littleweb.rs
Normal file
211
crates/app/src/routes/pages/littleweb.rs
Normal file
|
@ -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<State>,
|
||||
) -> 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<State>,
|
||||
) -> 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<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<FileBrowserProps>,
|
||||
) -> 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<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> 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<State>,
|
||||
) -> 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<String>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> 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())
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -361,6 +361,9 @@ fn default_banned_usernames() -> Vec<String> {
|
|||
"search".to_string(),
|
||||
"journals".to_string(),
|
||||
"links".to_string(),
|
||||
"app".to_string(),
|
||||
"services".to_string(),
|
||||
"domains".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
use crate::model::{
|
||||
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)
|
||||
|
|
|
@ -126,5 +126,6 @@ impl DataManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
auto_method!(update_service_files(Vec<ServiceFsEntry>)@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<ServiceFsEntry>)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET files = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}");
|
||||
}
|
||||
|
|
|
@ -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<ServiceFsEntry> {
|
||||
///
|
||||
/// # Returns
|
||||
/// `(file, id path)`
|
||||
pub fn file(&self, path: &str) -> Option<(ServiceFsEntry, Vec<String>)> {
|
||||
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<String>) -> 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<ServiceFsEntry>,
|
||||
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",
|
||||
})
|
||||
macro_rules! domain_tld_display_match {
|
||||
($self:ident, $($tld:ident),+ $(,)?) => {
|
||||
match $self {
|
||||
$(
|
||||
Self::$tld => stringify!($tld).to_lowercase(),
|
||||
)+
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for DomainTld {
|
||||
macro_rules! domain_tld_strings {
|
||||
($($tld:ident),+ $(,)?) => {
|
||||
$(
|
||||
paste! {
|
||||
/// Constant from macro.
|
||||
const [<TLD_ $tld:snake:upper>]: LazyLock<String> = LazyLock::new(|| stringify!($tld).to_lowercase());
|
||||
}
|
||||
)+
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! domain_tld_from_match {
|
||||
($value:ident, $($tld:ident),+ $(,)?) => {
|
||||
{
|
||||
$(
|
||||
paste! {
|
||||
let [<$tld:snake:lower>] = &*[<TLD_ $tld:snake:upper>];
|
||||
}
|
||||
)+;
|
||||
|
||||
// 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 {
|
||||
match value {
|
||||
"bunny" => Self::Bunny,
|
||||
_ => Self::Bunny,
|
||||
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<Vec<&str>> = 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<usize> {
|
||||
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::<usize>() {
|
||||
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),
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-l10n"
|
||||
version = "10.0.0"
|
||||
version = "11.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-shared"
|
||||
version = "10.0.0"
|
||||
version = "11.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue