diff --git a/Cargo.lock b/Cargo.lock index 04404b7..640e373 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,7 +365,7 @@ dependencies = [ "pathbufd", "serde", "serde_json", - "tetratto-core", + "tetratto-core 15.0.2", "tetratto-shared", "toml 0.9.5", ] @@ -1370,6 +1370,7 @@ checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", "hashbrown", + "serde", ] [[package]] @@ -1419,6 +1420,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -2125,6 +2135,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -2292,7 +2323,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.12.1", "libc", "libfuzzer-sys", "log", @@ -2674,6 +2705,51 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_valid" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b615bed66931a7a9809b273937adc8a402d038b1e509d027fcaf62f084d33d1" +dependencies = [ + "indexmap", + "itertools 0.13.0", + "num-traits", + "once_cell", + "paste", + "regex", + "serde", + "serde_json", + "serde_valid_derive", + "serde_valid_literal", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "serde_valid_derive" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa1a5a21ea5aab06d2e6a6b59837d450fb2be9695be97735a711edfbe79ea07" +dependencies = [ + "itertools 0.13.0", + "paste", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "serde_valid_literal" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd07331596ea967dccf9a35bde71ecd757490e09827b938a5c6226c648e3a25e" +dependencies = [ + "paste", + "regex", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2837,6 +2913,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2916,8 +2998,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tawny" -version = "1.0.0" +version = "1.0.1" dependencies = [ + "ammonia", "axum", "axum-extra", "axum-image", @@ -2932,7 +3015,7 @@ dependencies = [ "serde", "serde_json", "tera", - "tetratto-core", + "tetratto-core 16.0.2", "tetratto-shared", "tokio", "toml 0.9.5", @@ -3013,6 +3096,34 @@ dependencies = [ "totp-rs", ] +[[package]] +name = "tetratto-core" +version = "16.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380eed8dec18b0dcda3440d47375a1bacf94e42fdcd93d464e27682d005bf356" +dependencies = [ + "async-recursion", + "base16ct", + "base64", + "bitflags 2.9.2", + "buckets-core", + "emojis", + "md-5", + "oiseau", + "paste", + "pathbufd", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_valid", + "tetratto-l10n", + "tetratto-shared", + "tokio", + "toml 0.9.5", + "totp-rs", +] + [[package]] name = "tetratto-l10n" version = "12.0.0" @@ -3585,6 +3696,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 2738940..0e8192f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tawny" -version = "1.0.0" +version = "1.0.1" edition = "2024" authors = ["trisuaso"] repository = "https://trisua.com/t/tawny" @@ -8,7 +8,7 @@ license = "AGPL-3.0-or-later" homepage = "https://tawny.cc" [dependencies] -tetratto-core = "15.0.1" +tetratto-core = "16.0.2" tetratto-shared = "12.0.6" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } pathbufd = "0.1.4" @@ -34,3 +34,4 @@ oiseau = { version = "0.1.2", default-features = false, features = ["postgres", buckets-core = "1.0.4" axum-image = "0.1.1" futures-util = "0.3.31" +ammonia = "4.1.1" diff --git a/app/public/app.js b/app/public/app.js index b74d6b2..0888af1 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -36,6 +36,10 @@ function media_theme_pref() { } globalThis.temporary_set_theme = (theme) => { + if (theme === "Auto") { + return; + } + document.documentElement.className = theme.toLowerCase(); if (theme === "Light") { @@ -206,3 +210,39 @@ globalThis.submitter_load = (submitter) => { }, }; }; + +// users search +let search_users_timeout; +function search_users(e) { + if (search_users_timeout) { + clearTimeout(search_users_timeout); + } + + if (e.target.value.trim().length === 0) { + return; + } + + search_users_timeout = setTimeout(() => { + fetch("/api/v1/auth/users/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prefix: e.target.value.trim(), + }), + }) + .then((res) => res.json()) + .then((res) => { + if (res.ok) { + document.getElementById("users_search").innerHTML = ""; + for (const username of res.payload) { + document.getElementById("users_search").innerHTML += + ``; + } + } else { + show_message(res.message, res.ok); + } + }); + }, 1000); +} diff --git a/app/public/messages.js b/app/public/messages.js index db9fc1c..09ed575 100644 --- a/app/public/messages.js +++ b/app/public/messages.js @@ -102,6 +102,10 @@ function sock_con() { if (document.getElementById(`message_${msg.body}`)) { document.getElementById(`message_${msg.body}`).remove(); } + } else if (msg.method === "MessageUpdate") { + const [id, content] = JSON.parse(msg.body); + document.getElementById(`${id}_body`).innerHTML = + await render_markdown(content); } else if (msg.method === "ReadReceipt") { setTimeout(() => { read_receipt(); @@ -179,3 +183,133 @@ function delete_message(id) { } }); } + +async function render_markdown(content) { + return await ( + await fetch("/api/v1/markdown", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }) + ).text(); +} + +function edit_message_ui(id) { + document.getElementById(`${id}_body`).classList.add("hidden"); + document.getElementById(`${id}_edit_area`).classList.remove("hidden"); +} + +function edit_message(id, e) { + e.preventDefault(); + + fetch(`/api/v1/messages/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: e.target.content.value, + }), + }) + .then((res) => res.json()) + .then(async (res) => { + if (!res.ok) { + show_message(res.message, res.ok); + } else { + document + .getElementById(`${id}_body`) + .classList.remove("hidden"); + document + .getElementById(`${id}_edit_area`) + .classList.add("hidden"); + } + }); +} + +function leave_chat(id) { + if (!confirm("Are you sure you would like to do this?")) { + return; + } + + fetch(`/api/v1/chats/${id}/leave`, { + method: "POST", + }) + .then((res) => res.json()) + .then((res) => { + show_message(res.message, res.ok); + }); +} + +function add_member_to_chat(e, chat_id) { + e.preventDefault(); + document.getElementById("add_user_dialog").close(); + fetch(`/api/v1/chats/${chat_id}/members/add/${e.target.username.value}`, { + method: "POST", + }) + .then((res) => res.json()) + .then((res) => { + show_message(res.message, res.ok); + }); +} + +function update_chat_info(id, info) { + fetch(`/api/v1/chats/${id}/info`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + info, + }), + }) + .then((res) => res.json()) + .then((res) => { + show_message(res.message, res.ok); + }); +} + +function rename_chat(id, info) { + const new_name = prompt("New name:"); + + if (!new_name) { + return; + } + + info.name = new_name; + update_chat_info(id, info); +} + +function remove_member_from_chat(chat_id, uid) { + if (!confirm("Are you sure you would like to do this?")) { + return; + } + + fetch(`/api/v1/chats/${chat_id}/members/remove/${uid}`, { + method: "POST", + }) + .then((res) => res.json()) + .then((res) => { + show_message(res.message, res.ok); + }); +} + +function create_direct_chat_with_user(id) { + fetch(`/api/v1/chats`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + style: "Direct", + members: [id], + }), + }) + .then((res) => res.json()) + .then((res) => { + if (!res.ok) { + show_message(res.message, res.ok); + } else { + window.location.href = `/chats/${res.payload}`; + } + }); +} diff --git a/app/public/style.css b/app/public/style.css index 89ea5db..574c5cc 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -1,12 +1,18 @@ +@import url("https://repodelivery.tetratto.com/tetratto-aux/lexend.css"); + :root { color-scheme: light dark; - - --color-super-lowered: oklch(87.1% 0.006 286.286); - --color-lowered: oklch(96.7% 0.001 286.375); - --color-surface: oklch(92.9% 0.013 255.508); - --color-raised: oklch(98.4% 0.003 247.858); - --color-super-raised: oklch(96.8% 0.007 247.896); - --color-text: hsl(0, 0%, 5%); + --hue: 16; + --sat: 6%; + --lit: 0%; + --color-surface: hsl(var(--hue), var(--sat), calc(90% - var(--lit))); + --color-lowered: hsl(var(--hue), var(--sat), calc(86% - var(--lit))); + --color-raised: hsl(var(--hue), var(--sat), calc(96% - var(--lit))); + --color-super-lowered: hsl(var(--hue), var(--sat), calc(82% - var(--lit))); + --color-super-raised: hsl(var(--hue), var(--sat), calc(99% - var(--lit))); + --color-text: hsl(0, 0%, 9%); + --color-text-raised: var(--color-text); + --color-text-lowered: var(--color-text); --color-link: #2949b2; --color-shadow: rgba(0, 0, 0, 0.08); @@ -30,7 +36,7 @@ --pad-3: 0.5rem; --pad-4: 1rem; - --radius: 0.2rem; + --radius: 6px; --nav-height: 36px; } @@ -43,12 +49,15 @@ .dark, .dark * { - --color-super-lowered: var(--color-super-raised); - --color-lowered: var(--color-raised); - --color-surface: oklch(21% 0.006 285.885); - --color-raised: oklch(27.4% 0.006 286.033); - --color-super-raised: oklch(37% 0.013 285.805); - --color-text: hsl(0, 0%, 95%); + --hue: 266; + --sat: 14%; + --lit: 12%; + --color-surface: hsl(var(--hue), var(--sat), calc(0% + var(--lit))); + --color-lowered: hsl(var(--hue), var(--sat), calc(6% + var(--lit))); + --color-raised: hsl(var(--hue), var(--sat), calc(2% + var(--lit))); + --color-super-lowered: hsl(var(--hue), var(--sat), calc(12% + var(--lit))); + --color-super-raised: hsl(var(--hue), var(--sat), calc(4% + var(--lit))); + --color-text: hsl(0, 0%, 91%); --color-link: #93c5fd; --color-red: hsl(0, 94%, 82%); @@ -66,6 +75,7 @@ body { line-height: 1.5; letter-spacing: 0.15px; font-family: + "Lexend", "Inter", "Poppins", "Roboto", @@ -212,6 +222,10 @@ video { background: var(--color-surface); } +.card .card { + border-radius: var(--radius); +} + /* button */ .button { --h: 36px; @@ -235,6 +249,10 @@ video { overflow: hidden; } +.button:not(nav *, .tab, .dropdown .inner *, .square) { + border-radius: var(--radius); +} + .button:disabled { opacity: 50%; cursor: not-allowed; @@ -303,6 +321,15 @@ video { background: var(--color-primary-lowered) !important; } +.button.big { + --h: 48px; + width: 100%; +} + +.button.icon_only { + width: var(--h); +} + /* dropdown */ .dropdown { position: relative; @@ -380,6 +407,8 @@ select { line-height: var(--h); border-left: solid 0px transparent; width: 100%; + resize: vertical; + border-radius: var(--radius); } input:not([type="checkbox"]):focus { @@ -570,6 +599,10 @@ h6 { width: -moz-max-content; position: relative; max-width: 100%; + + & * { + font-size: inherit; + } } h1 { @@ -714,6 +747,7 @@ dialog { margin: auto; padding: var(--pad-4); border: 0; + border-radius: var(--radius); } dialog .inner { @@ -765,9 +799,15 @@ menu.col { padding: var(--pad-2) var(--pad-3); background: var(--color-surface); color: var(--color-text); + border-radius: var(--radius); } .message.mine .body { background: var(--color-primary); color: var(--color-text-primary); + border-bottom-right-radius: 0; +} + +.message:not(.mine) .body { + border-bottom-left-radius: 0; } diff --git a/app/templates_src/chat.lisp b/app/templates_src/chat.lisp index 57ce425..b0fbe48 100644 --- a/app/templates_src/chat.lisp +++ b/app/templates_src/chat.lisp @@ -1,6 +1,6 @@ (text "{% extends \"root.lisp\" %} {% block head %}") (title - (text "{{ components::chat_name(chat=chat, members=members) }} - {{ name }}")) + (text "{{ components::chat_name(chat=chat, members=members) }} — {{ config.name }}")) (text "{% endblock %} {% block body %}") (div ("class" "flex w_full gap_2 justify_between items_center") @@ -9,11 +9,16 @@ (a ("class" "button tab camo") ("href" "/chats") - (text "chats")) + (text "chats") + (text "{% if user.missed_messages_count > 0 -%}") (b (text "({{ user.missed_messages_count }})")) (text "{%- endif %}")) (a ("class" "button tab") ("href" "/chats/{{ chat.id }}") - (text "{{ components::chat_name(chat=chat, members=members, advanced=true, avatar_size=\"18px\") }}")))) + (text "{{ components::chat_name(chat=chat, members=members, advanced=true, avatar_size=\"18px\") }}")) + (a + ("class" "button tab camo") + ("href" "/chats/{{ chat.id }}/manage") + (text "{{ icon \"settings-2\" }} Manage")))) (div ("class" "flex flex_col card_nest reverse") ("style" "flex: 1 0 auto") diff --git a/app/templates_src/chats.lisp b/app/templates_src/chats.lisp index 41b9bda..092a0b8 100644 --- a/app/templates_src/chats.lisp +++ b/app/templates_src/chats.lisp @@ -1,6 +1,6 @@ (text "{% extends \"root.lisp\" %} {% block head %}") (title - (text "My chats - {{ name }}")) + (text "My chats — {{ config.name }}")) (text "{% endblock %} {% block body %}") (div ("class" "flex w_full gap_2 justify_between items_center") @@ -9,9 +9,10 @@ (a ("class" "button tab") ("href" "/chats") - (text "chats"))) + (text "chats") + (text "{% if user.missed_messages_count > 0 -%}") (b (text "({{ user.missed_messages_count }})")) (text "{%- endif %}"))) (button - ("class" "button") + ("class" "button square") ("title" "Create chat") ("onclick" "document.getElementById('create_dialog').showModal()") (text "{{ icon \"plus\" }}"))) @@ -21,7 +22,7 @@ (div ("class" "card surface w_full flex justify_between items_center gap_2") (a - ("class" "flex gap_ch items_center") + ("class" "flush flex gap_ch items_center {% if not user.id in chat[0].last_message_read_by -%} yellow {%- endif %}") ("href" "/chats/{{ chat[0].id }}") (text "{{ components::chat_name(chat=chat[0], members=chat[1], advanced=true) }}")) (div @@ -29,7 +30,7 @@ (button ("onclick" "open_dropdown(event)") ("exclude" "dropdown") - ("class" "button") + ("class" "button icon_only big_icon") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -39,13 +40,10 @@ ("target" "_blank") (text "pop open")) - (text "{% if chat[0].style != \"Direct\" -%}") - ; group chat only - (button + (a ("class" "button") - ("onclick" "rename_gc('{{ chat[0].id }}')") - (text "rename")) - (text "{%- endif %}") + ("href" "/chats/{{ chat[0].id }}/manage") + (text "manage")) (button ("class" "button red") @@ -165,53 +163,7 @@ e.target.reset(); } }); - } - - let search_users_timeout; - function search_users(e) { - if (search_users_timeout) { - clearTimeout(search_users_timeout); - } - - if (e.target.value.trim().length === 0) { - return; - } - - search_users_timeout = setTimeout(() => { - fetch(\"/api/v1/auth/users/search\", { - method: \"POST\", - headers: { - \"Content-Type\": \"application/json\", - }, - body: JSON.stringify({ - prefix: e.target.value.trim(), - }), - }) - .then((res) => res.json()) - .then((res) => { - if (res.ok) { - document.getElementById(\"users_search\").innerHTML = \"\"; - for (const username of res.payload) { - document.getElementById(\"users_search\").innerHTML += ``; - } - } else { - show_message(res.message, res.ok); - } - }); - }, 1000); - } - - function leave_chat(id) { - if (!confirm(\"Are you sure you would like to do this?\")) { - return; - } - - fetch(`/api/v1/chats/${id}/leave`, { - method: \"POST\", - }) - .then((res) => res.json()) - .then((res) => { - show_message(res.message, res.ok); - }); }")) + +(script ("src" "/public/messages.js")) (text "{% endblock %}") diff --git a/app/templates_src/components.lisp b/app/templates_src/components.lisp index ade1d4d..acab48f 100644 --- a/app/templates_src/components.lisp +++ b/app/templates_src/components.lisp @@ -55,6 +55,10 @@ (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner surface") + (button + ("class" "button surface") + ("onclick" "edit_message_ui('{{ message.id }}')") + (text "edit")) (button ("class" "button surface red") ("onclick" "delete_message('{{ message.id }}')") @@ -63,6 +67,55 @@ (div ("class" "body no_p_margin") + ("id" "{{ message.id }}_body") (text "{{ message.content|markdown|safe }}")) - (text "{{ self::avatar(id=message.owner) }}")) + (form + ("class" "body hidden flex flex_row gap_ch") + ("id" "{{ message.id }}_edit_area") + ("onsubmit" "edit_message('{{ message.id }}', event)") + (textarea + ("name" "content") + ("required") + (text "{{ message.content|safe }}")) + (button + ("title" "Save") + ("class" "button") + (text "{{ icon \"check\" }}"))) + + (a + ("href" "/@{{ message.owner }}?redirect=true") + ("target" "_blank") + (text "{{ self::avatar(id=message.owner) }}"))) (text "{%- endmacro %}") + +(text "{% macro theme(user, theme_preference) -%} {% if user %} {% if user.settings.theme_hue -%}") +(style + (text ":root, * { + --hue: {{ user.settings.theme_hue }} !important; + }")) + +(text "{%- endif %} {% if user.settings.theme_sat -%}") +(style + (text ":root, * { + --sat: {{ user.settings.theme_sat }} !important; + }")) + +(text "{%- endif %} {% if user.settings.theme_lit -%}") +(style + (text ":root, * { + --lit: {{ user.settings.theme_lit }} !important; + }")) +(text "{%- endif %}") +(div + ("style" "display: none;") + (text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} {% if user.permissions|has_supporter -%}") + (style + (text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}")) + (text "{%- endif %}")) +(text "{%- endif %} {%- endmacro %} {% macro theme_color(color, css) -%} {% if color -%}") +(style + (text ":root, + * { + --{{ css }}: {{ color|color }} !important; + }")) +(text "{%- endif %} {%- endmacro %}") diff --git a/app/templates_src/confirm_dm.lisp b/app/templates_src/confirm_dm.lisp new file mode 100644 index 0000000..d1aba90 --- /dev/null +++ b/app/templates_src/confirm_dm.lisp @@ -0,0 +1,28 @@ +(text "{% extends \"root.lisp\" %} {% block head %}") +(title + (text "Message {{ profile.username }}? — {{ config.name }}")) +(text "{% endblock %} {% block body %}") +(div + ("class" "card flex flex_col gap_ch") + (p (text "Are you sure you would like to direct message ") (a ("href" "/@{{ profile.username }}") (text "{{ profile.username }}")) (text "?")) + (div + ("class" "flex gap_2") + (text "{% if user -%}") + (button + ("class" "button surface green") + ("onclick" "create_direct_chat_with_user('{{ profile.username }}')") + (text "{{ icon \"arrow-right\" }} Continue")) + (text "{% else %}") + (a + ("class" "button surface green") + ("href" "/login?redirect=/@{{ profile.username }}/confirm_dm") + (text "{{ icon \"arrow-right\" }} Sign in to message")) + (text "{%- endif %}") + + (a + ("class" "button surface red") + ("href" "/") + (text "{{ icon \"x\" }} Cancel")))) + +(script ("src" "/public/messages.js")) +(text "{% endblock %}") diff --git a/app/templates_src/error.lisp b/app/templates_src/error.lisp index 23b614a..4f105e6 100644 --- a/app/templates_src/error.lisp +++ b/app/templates_src/error.lisp @@ -1,6 +1,6 @@ (text "{% extends \"root.lisp\" %} {% block head %}") (title - (text "Error - {{ name }}")) + (text "Error — {{ config.name }}")) (link ("rel" "icon") ("href" "/public/favicon.svg")) (text "{% endblock %} {% block body %}") (div diff --git a/app/templates_src/index.lisp b/app/templates_src/index.lisp index 41155c2..4d1a225 100644 --- a/app/templates_src/index.lisp +++ b/app/templates_src/index.lisp @@ -1,8 +1,11 @@ (text "{% extends \"root.lisp\" %} {% block head %}") -(title - (text "{{ name }}")) +(text "{% if user -%}") +(meta ("http-equiv" "refresh") ("content" "0; /chats")) +(text "{% else %}") +(meta ("http-equiv" "refresh") ("content" "0; {{ config.service_hosts.tetratto|safe }}")) +(text "{%- endif %}") (text "{% endblock %} {% block body %}") (div ("class" "card") - (h1 (text "{{ name }}"))) + (a ("href" "{{ config.service_hosts.tetratto }}") (text "Sending you elsewhere..."))) (text "{% endblock %}") diff --git a/app/templates_src/login.lisp b/app/templates_src/login.lisp index 08476c8..f7588fb 100644 --- a/app/templates_src/login.lisp +++ b/app/templates_src/login.lisp @@ -1,10 +1,10 @@ (text "{% extends \"root.lisp\" %} {% block head %}") (title - (text "Login — {{ name }}")) + (text "Login — {{ config.name }}")) (text "{% endblock %} {% block body %}") (div ("class" "card container small") - (h4 (text "Login with Tetratto")) + (h4 ("class" "text_center w_full") (text "Log in with Tetratto")) (form ("class" "flex flex_col gap_4") @@ -63,6 +63,7 @@ document.getElementById(`flow_${flow_page}`).style.display = \"contents\"; } + const search = new URLSearchParams(window.location.search); async function login(e) { e.preventDefault(); @@ -101,7 +102,11 @@ // redirect setTimeout(() => { - window.location.href = \"/chats\"; + if (search.get(\"redirect\") && (search.get(\"redirect\").startsWith(window.location.origin) || search.get(\"redirect\").startsWith(\"/\"))) { + window.location.href = search.get(\"redirect\"); + } else { + window.location.href = \"/chats\"; + } }, 150); } }); diff --git a/app/templates_src/manage.lisp b/app/templates_src/manage.lisp new file mode 100644 index 0000000..b7a43d0 --- /dev/null +++ b/app/templates_src/manage.lisp @@ -0,0 +1,117 @@ +(text "{% extends \"root.lisp\" %} {% block head %}") +(title + (text "Manage {{ components::chat_name(chat=chat, members=members) }} — {{ config.name }}")) +(text "{% endblock %} {% block body %}") +(div + ("class" "flex w_full gap_2 justify_between items_center") + (div + ("class" "tabs short bar flex") + (a + ("class" "button tab camo") + ("href" "/chats") + (text "chats")) + (a + ("class" "button tab camo") + ("href" "/chats/{{ chat.id }}") + (text "{{ components::chat_name(chat=chat, members=members, advanced=true, avatar_size=\"18px\") }}")) + (a + ("class" "button tab") + ("href" "/chats/{{ chat.id }}/manage") + (text "{{ icon \"settings-2\" }} Manage")))) +(div + ("class" "flex flex_col gap_4 card") + ("style" "flex: 1 0 auto") + (text "{% if chat.style != \"Direct\" -%}") + ; gc only + (button + ("class" "button surface") + ("onclick" "rename_chat('{{ chat.id }}', GC_INFO)") + (text "{{ icon \"pencil\" }} rename chat")) + + (script + ("type" "application/json") + ("id" "gc_info") + (text "{{ chat.style.Group|json_encode() }}")) + (script + (text "globalThis.GC_INFO = JSON.parse(document.getElementById(\"gc_info\").innerHTML)")) + (text "{%- endif %}") + + (ul + (li (b (text "Chat name: ")) (span (text "{{ components::chat_name(chat=chat, members=members) }}"))) + (li (b (text "Chat created: ")) (span (text "{{ chat.created / 1000|int|date(format=\"%Y-%m-%d %H:%M\", timezone=\"Etc/UTC\") }} UTC"))) + (li (b (text "Last message: ")) (span (text "{{ chat.last_message_created / 1000|int|date(format=\"%Y-%m-%d %H:%M\", timezone=\"Etc/UTC\") }} UTC"))) + (li + (div + ("class" "flex items_center gap_ch") + (b (text "Owner:")) + (a + ("class" "flex items_center gap_1 yellow") + ("href" "{{ config.service_hosts.tetratto }}/@{{ members[0].username }}") + (text "{{ icon \"crown\" }} {{ components::username(user=members[0]) }}"))))) + + (hr) + (div + ("class" "flex w_full justify_between items_center gap_2") + (h4 (text "Members") ("style" "margin: 0")) + (div + ("class" "flex gap_2") + (text "{% if chat.style != \"Direct\" -%}") + ; gc only + (button ("class" "green button surface") ("onclick" "document.getElementById('add_user_dialog').showModal()") (text "add")) + (text "{%- endif %}") + + (button ("class" "red button surface") ("onclick" "leave_chat('{{ chat.id }}')") (text "leave")))) + + (div + ("class" "flex flex_col gap_2") + (text "{% for member in members -%}") + (div + ("class" "card surface w_full flex flex_col gap_ch") + (a + ("class" "flush flex items_center gap_ch") + ("href" "/@{{ member.username }}") + (text "{{ components::avatar(id=member.id) }}") + (text "{{ components::username(user=member) }}")) + (span (text "{% if member.settings.status|length > 0 -%} {{ member.settings.status|markdown|safe }} {%- else -%} No status {%- endif %}")) + (text "{% if is_owner -%}") + (button + ("class" "red button") + ("onclick" "remove_member_from_chat('{{ chat.id }}', '{{ member.id }}')") + (text "remove")) + (text "{%- endif %}")) + (text "{%- endfor %}"))) + +(dialog + ("id" "add_user_dialog") + (form + ("class" "inner") + ("onsubmit" "add_member_to_chat(event, '{{ chat.id }}')") + (h2 + ("class" "text_center w_full") + (text "Add user")) + + (input + ("type" "text") + ("list" "users_search") + ("name" "username") + ("id" "username") + ("placeholder" "username") + ("oninput" "search_users(event)")) + + (hr ("class" "margin")) + + (div + ("class" "flex gap_2 justify_between") + (button + ("onclick" "document.getElementById('add_user_dialog').close()") + ("class" "button red") + ("type" "button") + (text "Cancel")) + + (button + ("class" "button green") + (text "Add"))))) +(datalist ("id" "users_search")) + +(script ("src" "/public/messages.js")) +(text "{% endblock %}") diff --git a/app/templates_src/profile.lisp b/app/templates_src/profile.lisp new file mode 100644 index 0000000..a644c9c --- /dev/null +++ b/app/templates_src/profile.lisp @@ -0,0 +1,157 @@ +(text "{% extends \"root.lisp\" %} {% block head %}") +(title + (text "{{ profile.username }} — {{ config.name }}")) + +(meta + ("name" "og:title") + ("content" "{{ profile.username }}")) + +(meta + ("name" "description") + ("content" "Message @{{ profile.username }} on {{ config.name }}!")) + +(meta + ("name" "og:description") + ("content" "Message @{{ profile.username }} on {{ config.name }}!")) + +(meta + ("property" "og:type") + ("content" "profile")) + +(meta + ("property" "profile:username") + ("content" "{{ profile.username }}")) + +(meta + ("name" "og:image") + ("content" "{{ config.service_hosts.buckets|safe }}/avatars/{{ profile.id }}")) + +(meta + ("name" "twitter:image") + ("content" "{{ config.service_hosts.buckets|safe }}/avatars/{{ profile.id }}")) + +(meta + ("name" "twitter:card") + ("content" "summary")) + +(meta + ("name" "twitter:title") + ("content" "{{ profile.username }}")) + +(meta + ("name" "twitter:description") + ("content" "Message @{{ profile.username }} on {{ config.name }}!")) +(text "{% endblock %} {% block body %}") +(text "{% if not use_user_theme -%} {{ components::theme(user=profile, theme_preference=profile.settings.profile_theme) }} {%- endif %}") + +(text "{% if profile.settings.profile_theme -%}") +(script + (text "setTimeout(() => { + temporary_set_theme(\"{{ profile.settings.profile_theme }}\"); + }, 150);")) +(text "{%- endif %}") + +(div + ("class" "flex flex_col gap_ch justify_center items_center profile") + ("style" "height: calc(100dvh - var(--nav-height))") + (div + ("class" "card_nest w_full") + ("style" "max-width: 25rem") + (div + ("class" "card banner")) + (div + ("class" "card flex flex_col gap_ch") + (text "{{ components::avatar(id=profile.id, size=\"160px\") }}") + (div + ("class" "w_full flex items_center justify_between gap_2") + (h2 (text "{{ components::username(user=profile) }}")) + (button + ("onclick" "document.getElementById('user_info').showModal()") + ("class" "button icon_only big_icon") + ("title" "User info") + (text "{{ icon \"info\" }}"))) + + (div (text "{{ profile.settings.biography|markdown|safe }}")) + + ; user links + (div + ("class" "flex flex_col gap_2") + ("style" "margin-top: 20px") + (text "{% for link in profile.settings.links -%}") + (a + ("class" "button big surface justify_start") + ("href" "{{ link[1] }}") + (text "{{ icon \"link\" }} {{ link[0] }}")) + (text "{%- endfor %}")) + + ; big action links + (div + ("class" "flex flex_row gap_2") + (a + ("class" "button big surface") + ("href" "{{ config.service_hosts.tetratto }}/@{{ profile.username }}") + (text "{{ icon \"external-link\" }} Full profile")) + + (text "{% if user -%} {% if profile.id != user.id -%}") + (button + ("class" "button big surface") + ("onclick" "create_direct_chat_with_user('{{ profile.username }}')") + (text "{{ icon \"message-circle\" }} Message")) + (text "{%- endif %} {% else %}") + (a + ("class" "button big surface") + ("href" "/login?redirect=/@{{ profile.username }}/confirm_dm") + (text "{{ icon \"message-circle\" }} Message")) + (text "{%- endif %}"))))) + +(dialog + ("id" "user_info") + (div + ("class" "inner") + (h2 + ("class" "text_center w_full") + (text "User info")) + + (ul + (li (b (text "Username: ")) (text "{{ profile.username }}")) + (li (b (text "Joined: ")) (text "{{ profile.created / 1000|int|date(format=\"%Y-%m-%d %H:%M\", timezone=\"Etc/UTC\") }} UTC")) + (li (b (text "Followers: ")) (a ("href" "{{ config.service_hosts.tetratto }}/@{{ profile.username }}/followers") (text "{{ profile.follower_count }}"))) + (li (b (text "Following: ")) (a ("href" "{{ config.service_hosts.tetratto }}/@{{ profile.username }}/following") (text "{{ profile.following_count }}"))) + (li (b (text "Posts: ")) (a ("href" "{{ config.service_hosts.tetratto }}/@{{ profile.username }}") (text "{{ profile.post_count }}")))) + + (hr ("class" "margin")) + (div + ("class" "flex gap_2 justify_right") + (button + ("onclick" "document.getElementById('user_info').close()") + ("class" "button red") + ("type" "button") + (text "Close"))))) + +(style + (text ".profile .avatar { + margin: -120px auto 0; + } + + .profile .banner { + background: url(\"{{ config.service_hosts.buckets }}/banners/{{ profile.id }}\") no-repeat center !important; + background-size: cover !important; + border-radius: var(--radius) var(--radius) 0 0; + height: 225px; + } + + .card_nest .card:nth-child(2) { + border-radius: 0 0 var(--radius) var(--radius); + } + + .profile h2 { + margin: 0; + } + + @media screen and (max-width: 900px) { + .card_nest .card { + border-radius: 0; + } + }")) +(script ("src" "/public/messages.js")) +(text "{% endblock %}") diff --git a/app/templates_src/root.lisp b/app/templates_src/root.lisp index 1bc18b2..f5bb3f9 100644 --- a/app/templates_src/root.lisp +++ b/app/templates_src/root.lisp @@ -12,10 +12,10 @@ (meta ("name" "theme-color") ("content" "{{ theme_color }}")) (meta ("property" "og:type") ("content" "website")) - (meta ("property" "og:site_name") ("content" "{{ name }}")) + (meta ("property" "og:site_name") ("content" "{{ config.name }}")) - (meta ("property" "og:title") ("content" "{{ name }}")) - (meta ("property" "twitter:title") ("content" "{{ name }}")) + (meta ("property" "og:title") ("content" "{{ config.name }}")) + (meta ("property" "twitter:title") ("content" "{{ config.name }}")) (link ("rel" "icon") ("href" "/public/favicon.svg")) (script ("src" "/public/app.js?v={{ build_code }}") ("defer")) @@ -24,6 +24,9 @@ (text "{% block head %}{% endblock %}")) (body + ("class" "{% if user and user.settings.use_system_font -%} use_system_font {%- endif %}") + (text "{% if user and use_user_theme -%} {{ components::theme(user=user, theme_preference=user.settings.theme_preference) }} {%- endif %}") + ; nav (nav ("class" "flex w_full justify_between gap_2") @@ -44,7 +47,7 @@ ("class" "inner left") (a ("class" "button") - ("href" "/") + ("href" "{% if user -%} /chats {%- else -%} / {%- endif %}") (text "home")) (a ("class" "button") @@ -61,10 +64,6 @@ ("href" "{{ config.service_hosts.tetratto }}/auth/register") (text "sign up")) (text "{%- else -%}") - (a - ("class" "button") - ("href" "/chats") - (text "my chats")) (a ("class" "button") ("href" "{{ config.service_hosts.tetratto }}/settings") @@ -74,7 +73,17 @@ ("onclick" "user_logout()") (text "logout")) (text "{%- endif %}") - (text "{% block dropdown %}{% endblock %}")))) + (text "{% block dropdown %}{% endblock %}"))) + (text "{% if user -%}") + (a + ("href" "{{ config.service_hosts.tetratto }}/notifs") + ("class" "button camo fade") + (text "{% if user.notification_count > 0 -%}") + (span ("class" "red") (text "{{ icon \"bell-dot\" }}")) + (text "{% else %}") + (text "{{ icon \"bell\" }}") + (text "{%- endif %}")) + (text "{%- endif %}")) (div ("class" "side flex") diff --git a/src/config.rs b/src/config.rs index a3b7aa1..eaf1775 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,8 @@ pub struct Config { /// buckets config. #[serde(default = "default_uploads_dir")] pub uploads_dir: String, + /// The host of this service. Required for notifications. + pub host: String, /// Database configuration. #[serde(default = "default_database")] pub database: DatabaseConfig, @@ -72,6 +74,7 @@ impl Default for Config { real_ip_header: default_real_ip_header(), service_hosts: default_service_hosts(), uploads_dir: default_uploads_dir(), + host: String::new(), database: default_database(), } } diff --git a/src/database/chats.rs b/src/database/chats.rs index adc5e6d..e3b2424 100644 --- a/src/database/chats.rs +++ b/src/database/chats.rs @@ -1,6 +1,6 @@ use super::DataManager; use crate::model::{Chat, ChatStyle}; -use oiseau::{PostgresRow, cache::Cache, execute, get, params, query_rows}; +use oiseau::{PostgresRow, cache::Cache, execute, get, params, query_row, query_rows}; use tetratto_core::{ auto_method, model::{Error, Result, auth::User}, @@ -44,6 +44,30 @@ impl DataManager { out } + /// Get the direct message chat between the two given members. + pub async fn get_direct_chat_by_members(&self, u1: usize, u2: usize) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM t_chats WHERE members = $1 OR members = $2", + params![ + &serde_json::to_string(&[u1, u2]).unwrap(), + &serde_json::to_string(&[u2, u1]).unwrap(), + ], + |x| { Ok(Self::get_chat_from_row(x)) } + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(res.unwrap()) + } + /// Get all chats the given member is participating in. pub async fn get_chats_by_member( &self, diff --git a/src/database/messages.rs b/src/database/messages.rs index 5d39cfd..7639e3d 100644 --- a/src/database/messages.rs +++ b/src/database/messages.rs @@ -232,7 +232,7 @@ impl DataManager { message.chat, SocketMessage { method: SocketMethod::MessageUpdate, - body: serde_json::to_string(&(message.id, content)).unwrap(), + body: serde_json::to_string(&(message.id.to_string(), content)).unwrap(), } .to_string(), ) { diff --git a/src/main.rs b/src/main.rs index 6427067..70f01e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod macros; mod markdown; mod model; mod routes; +mod sanitize; use crate::database::DataManager; use axum::{Extension, Router}; @@ -12,7 +13,7 @@ use config::Config; use nanoneo::core::element::Render; use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc}; use tera::{Tera, Value}; -use tetratto_core::html; +use tetratto_core::{html, model::permissions::FinePermission}; use tetratto_shared::hash::salt; use tokio::sync::RwLock; use tower_http::{ @@ -31,6 +32,17 @@ fn render_markdown(value: &Value, _: &HashMap) -> tera::Result) -> tera::Result { + Ok(sanitize::color_escape(value.as_str().unwrap()).into()) +} + +fn check_supporter(value: &Value, _: &HashMap) -> tera::Result { + Ok(FinePermission::from_bits(value.as_u64().unwrap() as u32) + .unwrap() + .check(FinePermission::SUPPORTER) + .into()) +} + fn remove_script_tags(value: &Value, _: &HashMap) -> tera::Result { Ok(value .as_str() @@ -103,6 +115,8 @@ async fn main() { tera.register_filter("markdown", render_markdown); tera.register_filter("remove_script_tags", remove_script_tags); + tera.register_filter("color", color_escape); + tera.register_filter("has_supporter", check_supporter); // create app let app = Router::new() diff --git a/src/model.rs b/src/model.rs index 2a35f1f..f49c97c 100644 --- a/src/model.rs +++ b/src/model.rs @@ -46,6 +46,15 @@ impl Chat { last_message_read_by: Vec::new(), } } + + /// Get the chat's owner. + pub fn owner(&self) -> usize { + if self.style == ChatStyle::Direct { + return 0; + } + + *self.members.first().unwrap() + } } #[derive(Clone, Serialize, Deserialize)] diff --git a/src/routes/api/chats.rs b/src/routes/api/chats.rs index eac1074..b131b46 100644 --- a/src/routes/api/chats.rs +++ b/src/routes/api/chats.rs @@ -17,7 +17,10 @@ use futures_util::{sink::SinkExt, stream::StreamExt}; use oiseau::cache::{Cache, redis::Commands}; use serde::Deserialize; use std::time::Duration; -use tetratto_core::model::{ApiReturn, Error, auth::User}; +use tetratto_core::model::{ + ApiReturn, Error, + auth::{Notification, User}, +}; #[derive(Deserialize)] pub struct CreateChat { @@ -28,20 +31,42 @@ pub struct CreateChat { pub async fn create_request( jar: CookieJar, Extension(data): Extension, - Json(req): Json, + Json(mut req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data.2) { Some(x) => x, None => return Json(Error::NotAllowed.into()), }; + req.members.dedup(); - if req.members.len() > 2 && req.style == ChatStyle::Direct { + if (req.members.len() > 2 && req.style == ChatStyle::Direct) + | (req.members.len() > 10 && req.style != ChatStyle::Direct) + { return Json(Error::DataTooLong("members list".to_string()).into()); } else if req.members.len() < 1 { return Json(Error::DataTooShort("members list".to_string()).into()); } + if req.style == ChatStyle::Direct + && let Ok(chat) = data + .get_direct_chat_by_members( + user.id, + match data.2.get_user_by_username(&req.members[0]).await { + Ok(x) => x.id, + Err(e) => return Json(e.into()), + }, + ) + .await + { + // if we already have a direct chat with this person, don't create a new one + return Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: chat.id.to_string(), + }); + } + match data .create_chat(Chat::new(req.style, { let mut x = Vec::new(); @@ -49,7 +74,19 @@ pub async fn create_request( x.push(user.id); for y in req.members { x.push(match data.2.get_user_by_username(&y).await { - Ok(x) => x.id, + Ok(x) => { + if x.settings.private_chats + && data + .2 + .get_userfollow_by_initiator_receiver(x.id, user.id) + .await + .is_err() + { + continue; + } + + x.id + } Err(e) => return Json(e.into()), }) } @@ -114,6 +151,119 @@ pub async fn leave_request( } } +pub async fn add_member_request( + jar: CookieJar, + Extension(data): Extension, + Path((id, username)): Path<(usize, String)>, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data.2) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + // get other user and check if we're blocked + let other_user = match data.2.get_user_by_username(&username).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if data + .2 + .get_userblock_by_initiator_receiver(other_user.id, user.id) + .await + .is_ok() + { + return Json(Error::NotAllowed.into()); + } + + if other_user.settings.private_chats + && data + .2 + .get_userfollow_by_initiator_receiver(other_user.id, user.id) + .await + .is_err() + { + return Json(Error::NotAllowed.into()); + } + + // ... + let mut chat = match data.get_chat_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if chat.style == ChatStyle::Direct || chat.members.contains(&other_user.id) { + return Json(Error::NotAllowed.into()); + } + + if chat.style != ChatStyle::Direct && chat.members.len() > 10 { + // can only have a maximum of 10 members in one chat + return Json(Error::DataTooLong("members list".to_string()).into()); + } + + chat.members.push(other_user.id); + match data.update_chat_members(chat.id, chat.members).await { + Ok(_) => { + if let Err(e) = data + .2 + .create_notification(Notification::new( + "You've been added to a chat!".to_string(), + format!( + "You were added to a [chat]({}/chats/{}) by [@{}](/api/v1/auth/user/find/{})", + data.0.0.host, chat.id, user.username, user.id + ), + other_user.id, + )) + .await + { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }) + } + Err(e) => Json(e.into()), + } +} + +pub async fn remove_member_request( + jar: CookieJar, + Extension(data): Extension, + Path((id, uid)): Path<(usize, usize)>, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data.2) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let mut chat = match data.get_chat_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if chat.style == ChatStyle::Direct || !chat.members.contains(&uid) || !(chat.owner() == user.id) + { + return Json(Error::NotAllowed.into()); + } + + chat.members + .remove(chat.members.iter().position(|x| *x == uid).unwrap()); + + match data.update_chat_members(chat.id, chat.members).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + #[derive(Deserialize)] pub struct UpdateChatInfo { pub info: GroupChatInfo, diff --git a/src/routes/api/messages.rs b/src/routes/api/messages.rs index ca021ad..4d90326 100644 --- a/src/routes/api/messages.rs +++ b/src/routes/api/messages.rs @@ -1,4 +1,4 @@ -use crate::{State, get_user_from_token, model::Message}; +use crate::{State, get_user_from_token, markdown::render_markdown, model::Message}; use axum::{Extension, Json, body::Bytes, extract::Path, response::IntoResponse}; use axum_extra::extract::CookieJar; use axum_image::{encode::save_webp_buffer, extract::JsonMultipart}; @@ -87,6 +87,17 @@ pub async fn create_request( } } + // update users + for member in chat.members { + if member == user.id { + continue; + } + + if let Err(e) = data.2.incr_user_missed_messages(member).await { + return Json(e.into()); + } + } + // ... Json(ApiReturn { ok: true, @@ -145,3 +156,7 @@ pub async fn update_content_request( Err(e) => Json(e.into()), } } + +pub async fn render_markdown_request(Json(req): Json) -> impl IntoResponse { + render_markdown(&req.content) +} diff --git a/src/routes/api/mod.rs b/src/routes/api/mod.rs index 3248ac2..a48b844 100644 --- a/src/routes/api/mod.rs +++ b/src/routes/api/mod.rs @@ -6,6 +6,7 @@ use axum::routing::{Router, delete, get, post, put}; pub fn routes() -> Router { Router::new() + .route("/markdown", post(messages::render_markdown_request)) // auth .route("/auth/login", post(auth::login_request)) .route("/auth/logout", post(auth::logout_request)) @@ -18,6 +19,14 @@ pub fn routes() -> Router { // chats .route("/chats", post(chats::create_request)) .route("/chats/{id}/leave", post(chats::leave_request)) + .route( + "/chats/{id}/members/add/{username}", + post(chats::add_member_request), + ) + .route( + "/chats/{id}/members/remove/{uid}", + post(chats::remove_member_request), + ) .route("/chats/{id}/info", post(chats::update_info_request)) .route("/chats/{id}/_connect", get(chats::subscription_handler)) .route( diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 1d694b8..6d1e059 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -19,10 +19,10 @@ pub fn routes() -> Router { pub fn default_context(config: &Config, build_code: &str, user: &Option) -> Context { let mut ctx = Context::new(); - ctx.insert("name", &config.name); ctx.insert("theme_color", &config.theme_color); ctx.insert("build_code", &build_code); ctx.insert("user", &user); ctx.insert("config", &config); + ctx.insert("use_user_theme", &true); ctx } diff --git a/src/routes/pages/chats.rs b/src/routes/pages/chats.rs index c2fa5f5..1521bae 100644 --- a/src/routes/pages/chats.rs +++ b/src/routes/pages/chats.rs @@ -27,6 +27,10 @@ pub async fn list_request( } }; + if let Err(e) = data.2.update_user_missed_messages_count(user.id, 0).await { + return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await); + } + let chats = match data.get_chats_by_member(user.id, 12, props.page).await { Ok(x) => data.fill_chats(x).await, Err(e) => { @@ -182,3 +186,42 @@ pub async fn read_receipt_request( ctx.insert("chat", &chat); Ok(Html(tera.render("read_receipt.lisp", &ctx).unwrap())) } + +pub async fn manage_chat_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let (ref data, ref tera, ref build_code) = *data.read().await; + let user = match get_user_from_token!(jar, data.2) { + Some(x) => x, + None => { + return Err(render_error(Error::NotAllowed, tera, data.0.0.clone(), None).await); + } + }; + + let (chat, members) = match data.get_chat_by_id(id).await { + Ok(x) => { + if !x.members.contains(&user.id) { + return Err( + render_error(Error::NotAllowed, tera, data.0.0.clone(), Some(user)).await, + ); + } + + data.fill_chat(x).await + } + Err(e) => { + return Err(render_error(e, tera, data.0.0.clone(), Some(user)).await); + } + }; + + let is_owner = chat.owner() == user.id; + + let mut ctx = default_context(&data.0.0, &build_code, &Some(user)); + + ctx.insert("chat", &chat); + ctx.insert("members", &members); + ctx.insert("is_owner", &is_owner); + + Ok(Html(tera.render("manage.lisp", &ctx).unwrap())) +} diff --git a/src/routes/pages/misc.rs b/src/routes/pages/misc.rs index 3a8f5a0..725d64f 100644 --- a/src/routes/pages/misc.rs +++ b/src/routes/pages/misc.rs @@ -1,9 +1,11 @@ use crate::{State, config::Config, get_user_from_token, routes::default_context}; use axum::{ Extension, + extract::{Path, Query}, response::{Html, IntoResponse}, }; use axum_extra::extract::CookieJar; +use serde::Deserialize; use tera::Tera; use tetratto_core::model::{Error, auth::User}; @@ -52,3 +54,72 @@ pub async fn login_request(jar: CookieJar, Extension(data): Extension) -> .unwrap(), ) } + +#[derive(Deserialize)] +pub struct ProfileQuery { + #[serde(default)] + pub redirect: bool, +} + +pub async fn profile_request( + jar: CookieJar, + Extension(data): Extension, + Path(username): Path, + Query(req): Query, +) -> impl IntoResponse { + let (ref data, ref tera, ref build_code) = *data.read().await; + let user = get_user_from_token!(jar, data.2); + + let profile = match data.2.get_user_by_username(&username).await { + Ok(x) => x, + Err(e) => { + return Err(render_error(e, tera, data.0.0.clone(), user).await); + } + }; + + if req.redirect { + return Ok(Html(format!( + "", + profile.username + ))); + } + + let is_self = if let Some(ref ua) = user { + ua.id == profile.id + } else { + false + }; + + let mut ctx = default_context(&data.0.0, &build_code, &user); + + ctx.insert("profile", &profile); + if let Some(ua) = user { + if !ua.settings.disable_other_themes | is_self { + ctx.insert("use_user_theme", &false); + } + } else { + ctx.insert("use_user_theme", &false); + } + + Ok(Html(tera.render("profile.lisp", &ctx).unwrap())) +} + +pub async fn confirm_dm_request( + jar: CookieJar, + Extension(data): Extension, + Path(username): Path, +) -> impl IntoResponse { + let (ref data, ref tera, ref build_code) = *data.read().await; + let user = get_user_from_token!(jar, data.2); + + let profile = match data.2.get_user_by_username(&username).await { + Ok(x) => x, + Err(e) => { + return Err(render_error(e, tera, data.0.0.clone(), user).await); + } + }; + + let mut ctx = default_context(&data.0.0, &build_code, &user); + ctx.insert("profile", &profile); + Ok(Html(tera.render("confirm_dm.lisp", &ctx).unwrap())) +} diff --git a/src/routes/pages/mod.rs b/src/routes/pages/mod.rs index efd561b..e5618ec 100644 --- a/src/routes/pages/mod.rs +++ b/src/routes/pages/mod.rs @@ -7,11 +7,15 @@ use serde::Deserialize; pub fn routes() -> Router { Router::new() .route("/", get(misc::index_request)) + // profile + .route("/@{username}", get(misc::profile_request)) + .route("/@{username}/confirm_dm", get(misc::confirm_dm_request)) // auth .route("/login", get(misc::login_request)) // chats .route("/chats", get(chats::list_request)) .route("/chats/{id}", get(chats::chat_request)) + .route("/chats/{id}/manage", get(chats::manage_chat_request)) .route( "/chats/_templates/message/{id}", get(chats::single_message_request), diff --git a/src/sanitize.rs b/src/sanitize.rs new file mode 100644 index 0000000..1163c68 --- /dev/null +++ b/src/sanitize.rs @@ -0,0 +1,28 @@ +use ammonia::Builder; + +/// Escape profile colors +pub fn color_escape(color: &str) -> String { + remove_tags( + &color + .replace(";", "") + .replace("<", "<") + .replace(">", "%gt;") + .replace("}", "") + .replace("{", "") + .replace("url(\"", "url(\"/api/v1/util/proxy?url=") + .replace("url('", "url('/api/v1/util/proxy?url=") + .replace("url(https://", "url(/api/v1/util/proxy?url=https://"), + ) +} + +/// Clean profile metadata +pub fn remove_tags(input: &str) -> String { + Builder::default() + .rm_tags(&["img", "a", "span", "p", "h1", "h2", "h3", "h4", "h5", "h6"]) + .clean(input) + .to_string() + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace("", "