add: littleweb full

This commit is contained in:
trisua 2025-07-08 13:35:23 -04:00
parent 3fc0872867
commit d67e7c9c33
32 changed files with 1699 additions and 71 deletions

9
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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
}

View file

@ -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"

View file

@ -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'"),
));
}

View file

@ -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

View file

@ -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

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

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

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

View 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(\"&lt;/script&gt;\", \"</script\" + \">\"),
language: MIME_MODES[\"{{ file.mime }}\"],
theme: \"vs-dark\",
});
});"))
(text "{%- endif %}")
(text "{% endblock %}")

View file

@ -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

View file

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

View file

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

View file

@ -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();
});
})();

View 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();

View file

@ -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((

View file

@ -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)]

View file

@ -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(),

View file

@ -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"));

View file

@ -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)),

View file

@ -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()),
))

View 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())
}

View file

@ -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 {

View file

@ -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"

View file

@ -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(),
]
}

View file

@ -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)

View file

@ -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:{}");
}

View file

@ -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),
}

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-l10n"
version = "10.0.0"
version = "11.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-shared"
version = "10.0.0"
version = "11.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -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(