Compare commits

...

69 commits

Author SHA1 Message Date
46e38042ce fix: user notification/request counts 2025-07-26 22:28:45 -04:00
29155ddb0c add: mail base 2025-07-26 22:18:32 -04:00
a337e0c7c1 add: hide_social_follows setting 2025-07-25 13:39:34 -04:00
e78c43ab62 add: check ip ban endpoint 2025-07-23 14:44:47 -04:00
8786cb4781 add: render_markdown_dirty 2025-07-21 22:29:16 -04:00
9aed5de097 add: extended app storage limits 2025-07-20 20:19:33 -04:00
c757ddb77a fix: markdown autolinking with images 2025-07-20 16:41:50 -04:00
46849ba66c fix: hyphens in links 2025-07-20 16:18:56 -04:00
fe2e61118a add: use pulldown-cmark instead 2025-07-20 15:28:44 -04:00
3f70a8f465 chore: bump and publish shared 2025-07-20 15:09:34 -04:00
55460fc60a add: actually parse arrow alignment for markdown 2025-07-20 15:04:16 -04:00
d58e47cbbe fix: only add delta bytes when changing app data value 2025-07-20 03:33:03 -04:00
270d7550d6 fix: app data limits 2025-07-20 03:12:27 -04:00
6f2d556c65 add: app data rename method 2025-07-19 23:21:01 -04:00
35b66c94d0 chore: publish l10n, shared, and core 2025-07-19 21:30:41 -04:00
7d30d65a3b fix: profile panic 2025-07-19 15:38:58 -04:00
fe1e53c47a add: apps rust sdk 2025-07-19 15:31:06 -04:00
f05074ffc5 fix: delete apps and app_data when deleting user 2025-07-19 03:20:13 -04:00
63d3c2350d add: user is_deactivated 2025-07-19 03:17:21 -04:00
9ccbc69405 add: app sdk client auth flow example 2025-07-19 02:00:04 -04:00
0138bf4cd4 add: user requests in js app sdk 2025-07-19 00:44:12 -04:00
884a89904e add: channel mutes 2025-07-18 20:04:26 -04:00
02f3d08926 add: developer pass 2025-07-18 14:52:00 -04:00
636ecce9f4 add: apps js sdk 2025-07-18 13:22:25 -04:00
e393221b4f fix: check muted phrases while creating questions 2025-07-18 12:22:50 -04:00
22aea48cc5 add: better app data queries 2025-07-18 00:14:52 -04:00
9f61d9ce6a fix: post creation form 2025-07-17 13:51:56 -04:00
440ca81c25 fix: properly update app usage 2025-07-17 13:46:20 -04:00
f423daf2fc add: app_data api 2025-07-17 13:34:10 -04:00
5c520f4308 add: app_data table 2025-07-17 01:30:27 -04:00
f802a1c8ab chore: bump deps 2025-07-17 00:44:05 -04:00
d1c3643574 add: user ban_reason 2025-07-16 20:18:39 -04:00
b25bda29b8 fix: can_manage_posts permission 2025-07-16 18:36:56 -04:00
0256f38e5d fix: don't toggle follow when following back 2025-07-15 15:59:05 -04:00
70ecc6f96e add: manage followers page 2025-07-15 00:08:49 -04:00
959a125992 add: change default avatar 2025-07-14 22:05:59 -04:00
8dfd307919 fix: stripe notification spam 2025-07-14 16:54:55 -04:00
e0e38b2b32 add: upload alt text 2025-07-14 15:30:17 -04:00
3b5b0ce1a1 add: product uploads 2025-07-13 23:15:00 -04:00
292d302304 fix: regular question asking 2025-07-13 19:58:59 -04:00
052ddf862f fix: check permissions before asking about a post 2025-07-13 19:05:17 -04:00
73d8e9ab49 fix: don't show "ask about this" if owner has questions disabled 2025-07-13 18:43:36 -04:00
2c83ed3d9d add: "ask about this" from neospring 2025-07-13 18:42:08 -04:00
f94570f74c add: settings presets 2025-07-13 17:54:12 -04:00
cf2af1e1e9 add: products api 2025-07-13 15:28:55 -04:00
2be2409d66 fix: InvoicePaymentFailed event 2025-07-13 12:42:28 -04:00
ea13526515 add: product types 2025-07-13 00:50:16 -04:00
2705608903 add: product types 2025-07-13 00:05:28 -04:00
aea764948c add: ability to create seller account 2025-07-12 21:05:45 -04:00
e4468e4768 add: user seller_data 2025-07-12 18:06:36 -04:00
fdaa81422a add: better stripe endpoint 2025-07-12 16:30:57 -04:00
227cd3d2ac fix: user follows panic 2025-07-12 14:44:50 -04:00
6af56ed2b2 fix: atto links (relative) 2025-07-12 00:07:37 -04:00
4d49fc3cdf fix: littleweb browser url 2025-07-11 19:39:46 -04:00
cfcc2358f4 add: service edit date + browser session ids 2025-07-11 18:56:49 -04:00
9aee80493f fix: anonymous post page panic 2025-07-11 12:35:47 -04:00
14f3bf849e add: post full unlist option 2025-07-10 18:43:54 -04:00
bdd8f9a869 add: hide_from_social_lists user setting 2025-07-10 13:32:43 -04:00
4e152b07be add: littleweb (common) achievements 2025-07-09 22:59:28 -04:00
7960f1ed41 fix: nsfw posts in all/communities timelines 2025-07-09 22:29:54 -04:00
69067145ce fix: home timeline setting 2025-07-09 21:44:49 -04:00
7ead0ce775 fix: don't change link hrefs in littleweb browser 2025-07-08 18:29:59 -04:00
22a2545aa0 fix: littleweb browser page url bar 2025-07-08 18:25:47 -04:00
e72ccf9139 fix: mod panel secondary role builder 2025-07-08 17:52:39 -04:00
65e5d5f4e9 fix: user domains view for staff 2025-07-08 17:44:49 -04:00
388ccbf58c add: small littleweb browser changes 2025-07-08 17:38:24 -04:00
e7febc7c7e add: allow direct "atto://" links to work for script tags 2025-07-08 15:33:51 -04:00
78c9b3349d add: better domain editor ui 2025-07-08 15:21:57 -04:00
4ebd7e6c2b fix: "ask anonymously" checkbox 2025-07-08 14:36:14 -04:00
163 changed files with 6289 additions and 2358 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target
debug/
.dev

182
Cargo.lock generated
View file

@ -34,9 +34,9 @@ checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1"
[[package]]
name = "ammonia"
version = "4.1.0"
version = "4.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ada2ee439075a3e70b6992fce18ac4e407cd05aea9ca3f75d2c0b0c20bbb364"
checksum = "d6b346764dd0814805de8abf899fe03065bcee69bb1a4771c785817e39f3978f"
dependencies = [
"cssparser",
"html5ever",
@ -337,12 +337,6 @@ dependencies = [
"tokio-postgres",
]
[[package]]
name = "bberry"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee0ee2ee1f1a6094d77ba1bf5402f8a8d66e77f6353aff728e37249b2e77458"
[[package]]
name = "bit_field"
version = "0.10.2"
@ -961,6 +955,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@ -1124,12 +1127,11 @@ dependencies = [
[[package]]
name = "html5ever"
version = "0.31.0"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953cbbe631aae7fc0a112702ad5d3aaf09da38beaf45ea84610d6e1c358f569c"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"mac",
"markup5ever",
"match_token",
]
@ -1576,6 +1578,17 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "io-uring"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"libc",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@ -1739,20 +1752,11 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markdown"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb"
dependencies = [
"unicode-id",
]
[[package]]
name = "markup5ever"
version = "0.16.1"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a8096766c229e8c88a3900c9b44b7e06aa7f7343cc229158c3e58ef8f9973a"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril",
@ -1761,9 +1765,9 @@ dependencies = [
[[package]]
name = "match_token"
version = "0.1.0"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
@ -1871,6 +1875,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "nanoneo"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1495d19c5bed5372c613d7b4a38e8093b357f4405ce38ba1de2d6586e5c892"
[[package]]
name = "native-tls"
version = "0.2.14"
@ -2345,6 +2355,25 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
dependencies = [
"bitflags 2.9.1",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "qoi"
version = "0.4.1"
@ -2639,9 +2668,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.20"
version = "0.12.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
dependencies = [
"base64 0.22.1",
"bytes",
@ -2659,6 +2688,7 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@ -2874,9 +2904,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.140"
version = "1.0.141"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
dependencies = [
"itoa",
"memchr",
@ -2925,6 +2955,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@ -3191,7 +3230,7 @@ dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml",
"toml 0.8.23",
"version-compare",
]
@ -3249,19 +3288,20 @@ dependencies = [
[[package]]
name = "tetratto"
version = "11.0.0"
version = "12.0.0"
dependencies = [
"ammonia",
"async-stripe",
"axum",
"axum-extra",
"bberry",
"cf-turnstile",
"contrasted",
"cookie",
"emojis",
"futures-util",
"image",
"mime_guess",
"nanoneo",
"pathbufd",
"regex",
"reqwest",
@ -3280,7 +3320,7 @@ dependencies = [
[[package]]
name = "tetratto-core"
version = "11.0.0"
version = "12.0.2"
dependencies = [
"async-recursion",
"base16ct",
@ -3297,28 +3337,30 @@ dependencies = [
"serde_json",
"tetratto-l10n",
"tetratto-shared",
"toml",
"tokio",
"toml 0.9.2",
"totp-rs",
]
[[package]]
name = "tetratto-l10n"
version = "11.0.0"
version = "12.0.0"
dependencies = [
"pathbufd",
"serde",
"toml",
"toml 0.9.2",
]
[[package]]
name = "tetratto-shared"
version = "11.0.0"
version = "12.0.6"
dependencies = [
"ammonia",
"chrono",
"hex_fmt",
"markdown",
"pulldown-cmark",
"rand 0.9.1",
"regex",
"serde",
"sha2",
"snowflaked",
@ -3444,16 +3486,18 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.45.1"
version = "1.46.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"slab",
"socket2",
"tokio-macros",
"windows-sys 0.52.0",
@ -3548,11 +3592,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit",
]
[[package]]
name = "toml"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac"
dependencies = [
"indexmap",
"serde",
"serde_spanned 1.0.0",
"toml_datetime 0.7.0",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
@ -3562,6 +3621,15 @@ dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
@ -3570,17 +3638,25 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
name = "toml_parser"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64"
[[package]]
name = "totp-rs"
@ -3815,12 +3891,6 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-id"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10103c57044730945224467c09f71a4db0071c123a0648cc3e818913bde6b561"
[[package]]
name = "unicode-ident"
version = "1.0.18"
@ -3842,6 +3912,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
[[package]]
name = "unicode-width"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]]
name = "untrusted"
version = "0.9.0"

View file

@ -4,6 +4,7 @@ members = ["crates/app", "crates/shared", "crates/core", "crates/l10n"]
package.authors = ["trisuaso"]
package.repository = "https://trisua.com/t/tetratto"
package.license = "AGPL-3.0-or-later"
package.homepage = "https://tetratto.com"
[profile.dev]
incremental = true

View file

@ -1,7 +1,11 @@
[package]
name = "tetratto"
version = "11.0.0"
version = "12.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true
license.workspace = true
homepage.workspace = true
[dependencies]
pathbufd = "0.1.4"
@ -16,17 +20,16 @@ tower-http = { version = "0.6.6", features = [
"set-header",
] }
axum = { version = "0.8.4", features = ["macros", "ws"] }
tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] }
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
ammonia = "4.1.0"
ammonia = "4.1.1"
tetratto-shared = { path = "../shared" }
tetratto-core = { path = "../core" }
tetratto-l10n = { path = "../l10n" }
image = "0.25.6"
reqwest = { version = "0.12.20", features = ["json", "stream"] }
reqwest = { version = "0.12.22", features = ["json", "stream"] }
regex = "1.11.1"
serde_json = "1.0.140"
serde_json = "1.0.141"
mime_guess = "2.0.5"
cf-turnstile = "0.2.0"
contrasted = "0.1.3"
@ -37,7 +40,9 @@ async-stripe = { version = "0.41.0", features = [
"webhook-events",
"billing",
"runtime-tokio-hyper",
"connect",
] }
emojis = "0.7.0"
webp = "0.3.0"
bberry = "0.2.0"
nanoneo = "0.2.0"
cookie = "0.18.1"

View file

@ -1,21 +1,17 @@
use bberry::{
use nanoneo::{
core::element::{Element, Render},
text, read_param,
};
use pathbufd::PathBufD;
use regex::Regex;
use std::{
collections::HashMap,
fs::{exists, read_to_string, write},
sync::LazyLock,
time::SystemTime,
};
use std::{collections::HashMap, fs::read_to_string, sync::LazyLock, time::SystemTime};
use tera::Context;
use tetratto_core::{
config::Config,
html::{pull_icons, ICONS},
model::{
auth::{DefaultTimelineChoice, User},
permissions::FinePermission,
permissions::{FinePermission, SecondaryPermission},
},
};
use tetratto_l10n::LangFile;
@ -40,8 +36,8 @@ pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
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");
pub const APP_SDK_JS: &str = include_str!("./public/js/app_sdk.js");
// html
pub const BODY: &str = include_str!("./public/html/body.lisp");
@ -59,6 +55,7 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp");
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp");
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.lisp");
pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.lisp");
pub const AUTH_SELLER_CONNECTION: &str = include_str!("./public/html/auth/seller_connection.lisp");
pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.lisp");
pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.lisp");
@ -140,6 +137,8 @@ pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/servic
pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp");
pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp");
pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp");
// langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
@ -147,44 +146,13 @@ pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
pub const VENDOR_SPOTIFY_ICON: &str = include_str!("./public/images/vendor/spotify.svg");
pub const VENDOR_LAST_FM_ICON: &str = include_str!("./public/images/vendor/last-fm.svg");
pub const VENDOR_STRIPE_ICON: &str = include_str!("./public/images/vendor/stripe.svg");
pub const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny.webp");
pub(crate) static HTML_FOOTER: LazyLock<RwLock<String>> =
LazyLock::new(|| RwLock::new(String::new()));
/// A container for all loaded icons.
pub(crate) static ICONS: LazyLock<RwLock<HashMap<String, String>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
/// Pull an icon given its name and insert it into [`ICONS`].
pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) {
let writer = &mut ICONS.write().await;
let icon_url = format!(
"https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg"
);
let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]);
if exists(&file_path).unwrap() {
writer.insert(icon.to_string(), read_to_string(&file_path).unwrap());
return;
}
println!("download icon: {icon}");
let svg = reqwest::get(icon_url)
.await
.unwrap()
.text()
.await
.unwrap()
.replace("\n", "");
write(&file_path, &svg).unwrap();
writer.insert(icon.to_string(), svg);
}
macro_rules! vendor_icon {
($name:literal, $icon:ident, $icons_dir:expr) => {{
let writer = &mut ICONS.write().await;
@ -237,7 +205,7 @@ pub(crate) async fn replace_in_html(
input.to_string()
} else {
let start = SystemTime::now();
let parsed = bberry::parse(input);
let parsed = nanoneo::parse(input);
println!("parsed lisp in {}μs", start.elapsed().unwrap().as_micros());
if let Some(plugins) = plugins {
@ -257,56 +225,8 @@ pub(crate) async fn replace_in_html(
input = input.replace(cap.get(0).unwrap().as_str(), &replace_with);
}
// icon (with class)
let icon_with_class =
Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*c\\((.*?)\\)\\s*(\\}\\})").unwrap();
for cap in icon_with_class.captures_iter(&input.clone()) {
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
let icon = &(if cap_str.contains(" }}") {
cap_str.split(" }}").next().unwrap().to_string()
} else {
cap_str.to_string()
});
pull_icon(icon, &config.dirs.icons).await;
let reader = ICONS.read().await;
let icon_text = reader.get(icon).unwrap().replace(
"<svg",
&format!("<svg class=\"icon {}\"", cap.get(4).unwrap().as_str()),
);
input = input.replace(
&format!(
"{{{{ icon \"{cap_str}\" c({}) }}}}",
cap.get(4).unwrap().as_str()
),
&icon_text,
);
}
// icon (without class)
let icon_without_class = Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*(\\}\\})").unwrap();
for cap in icon_without_class.captures_iter(&input.clone()) {
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
let icon = &(if cap_str.contains(" }}") {
cap_str.split(" }}").next().unwrap().to_string()
} else {
cap_str.to_string()
});
pull_icon(icon, &config.dirs.icons).await;
let reader = ICONS.read().await;
let icon_text = reader
.get(icon)
.unwrap()
.replace("<svg", "<svg class=\"icon\"");
input = input.replace(&format!("{{{{ icon \"{cap_str}\" }}}}",), &icon_text);
}
// icons
input = pull_icons(input, &config.dirs.icons).await;
// return
input = input.replacen("<!-- html_footer_goes_here -->", &format!("{reader}"), 1);
@ -344,6 +264,7 @@ pub(crate) fn lisp_plugins() -> HashMap<String, Box<dyn FnMut(Element) -> Elemen
pub(crate) async fn write_assets(config: &Config) -> PathBufD {
vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons);
vendor_icon!("last_fm", VENDOR_LAST_FM_ICON, config.dirs.icons);
vendor_icon!("stripe", VENDOR_STRIPE_ICON, config.dirs.icons);
bin_icon!("tetratto_bunny.webp", TETRATTO_BUNNY, config.dirs.assets);
// ...
@ -365,6 +286,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins);
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config --lisp plugins);
write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config --lisp plugins);
write_template!(html_path->"auth/seller_connection.html"(crate::assets::AUTH_SELLER_CONNECTION) --config=config --lisp plugins);
write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config --lisp plugins);
write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config --lisp plugins);
@ -441,6 +363,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
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);
write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins);
html_path
}
@ -508,6 +432,11 @@ pub(crate) async fn initial_context(
"is_supporter",
&ua.permissions.check(FinePermission::SUPPORTER),
);
ctx.insert(
"has_developer_pass",
&ua.secondary_permissions
.check(SecondaryPermission::DEVELOPER_PASS),
);
ctx.insert("home", &ua.settings.default_timeline.relative_url());
} else {
ctx.insert("is_helper", &false);

68
crates/app/src/cookie.rs Normal file
View file

@ -0,0 +1,68 @@
use std::convert::Infallible;
use axum::{
extract::FromRequestParts,
http::{request::Parts, HeaderMap},
};
use cookie::{Cookie, CookieJar as CookieCookieJar};
/// This is required because "Cookie" his a forbidden header for some fucking reason.
/// Stupidest thing I've ever encountered in JavaScript, absolute fucking insanity.
///
/// Anyway, most of this shit is just from the original source for axum_extra::extract::CookieJar,
/// just edited to use X-Cookie instead.
///
/// Stuff from axum_extra will have links to the original provided.
pub struct CookieJar {
jar: CookieCookieJar,
}
/// <https://docs.rs/axum-extra/latest/src/axum_extra/extract/cookie/mod.rs.html#92-101>
impl<S> FromRequestParts<S> for CookieJar
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(Self::from_headers(&parts.headers))
}
}
fn cookies_from_request(
header: String,
headers: &HeaderMap,
) -> impl Iterator<Item = Cookie<'static>> + '_ {
headers
.get_all(header)
.into_iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(';'))
.filter_map(|cookie| Cookie::parse_encoded(cookie.to_owned()).ok())
}
impl CookieJar {
/// <https://docs.rs/axum-extra/latest/axum_extra/extract/cookie/struct.CookieJar.html#method.from_headers>
///
/// Modified only to prefer "X-Cookie" header.
pub fn from_headers(headers: &HeaderMap) -> Self {
let mut jar = CookieCookieJar::new();
for cookie in cookies_from_request(
if headers.contains_key("X-Cookie") {
"X-Cookie".to_string()
} else {
"Cookie".to_string()
},
headers,
) {
jar.add_original(cookie.clone());
}
Self { jar }
}
/// <https://docs.rs/axum-extra/latest/axum_extra/extract/cookie/struct.CookieJar.html#method.get>
pub fn get(&self, name: &str) -> Option<&Cookie<'static>> {
self.jar.get(name)
}
}

View file

@ -32,6 +32,7 @@ version = "1.0.0"
"general:action.copy_link" = "Copy link"
"general:action.copy_id" = "Copy ID"
"general:action.post" = "Post"
"general:action.apply" = "Apply"
"general:label.account" = "Account"
"general:label.safety" = "Safety"
"general:label.share" = "Share"
@ -130,6 +131,7 @@ version = "1.0.0"
"communities:label.edit_content" = "Edit content"
"communities:label.repost" = "Repost"
"communities:label.quote_post" = "Quote post"
"communities:label.ask_about_this" = "Ask about this"
"communities:label.search_results" = "Search results"
"communities:label.query" = "Query"
"communities:label.join_new" = "Join new"
@ -161,6 +163,7 @@ version = "1.0.0"
"settings:tab.sessions" = "Sessions"
"settings:tab.connections" = "Connections"
"settings:tab.images" = "Images"
"settings:tab.presets" = "Presets"
"settings:label.change_password" = "Change password"
"settings:label.current_password" = "Current password"
"settings:label.delete_account" = "Delete account"
@ -180,6 +183,11 @@ version = "1.0.0"
"settings:label.ips" = "IPs"
"settings:label.generate_invites" = "Generate invites"
"settings:label.add_to_stack" = "Add to stack"
"settings:label.alt_text" = "Alt text"
"settings:label.deactivate_account" = "Deactivate account"
"settings:label.activate_account" = "Activate account"
"settings:label.deactivate" = "Deactivate"
"settings:label.account_deactivated" = "Account deactivated"
"settings:tab.security" = "Security"
"settings:tab.blocks" = "Blocks"
"settings:tab.billing" = "Billing"
@ -194,6 +202,7 @@ version = "1.0.0"
"mod_panel:label.associations" = "Associations"
"mod_panel:label.invited_by" = "Invited by"
"mod_panel:label.send_debug_payload" = "Send debug payload"
"mod_panel:label.ban_reason" = "Ban reason"
"mod_panel:action.send" = "Send"
"requests:label.requests" = "Requests"
@ -217,6 +226,8 @@ version = "1.0.0"
"chats:action.add_someone" = "Add someone"
"chats:action.kick_member" = "Kick member"
"chats:action.mention_user" = "Mention user"
"chats:action.mute" = "Mute"
"chats:action.unmute" = "Unmute"
"stacks:link.stacks" = "Stacks"
"stacks:label.my_stacks" = "My stacks"
@ -229,6 +240,7 @@ version = "1.0.0"
"stacks:label.block_all" = "Block all"
"stacks:label.unblock_all" = "Unblock all"
"forge:label.forges" = "Forges"
"forge:label.my_forges" = "My forges"
"forge:label.create_new" = "Create new forge"
"forge:tab.info" = "Info"
@ -237,6 +249,7 @@ version = "1.0.0"
"forge:action.close" = "Close"
"developer:label.for_developers" = "for Developers"
"developer:label.apps" = "Apps"
"developer:label.my_apps" = "My apps"
"developer:label.create_new" = "Create new app"
"developer:label.homepage" = "Homepage"
@ -245,9 +258,13 @@ version = "1.0.0"
"developer:label.change_homepage" = "Change homepage"
"developer:label.change_redirect" = "Change redirect URL"
"developer:label.change_quota_status" = "Change quota status"
"developer:label.change_storage_capacity" = "Change storage capacity"
"developer:label.manage_scopes" = "Manage scopes"
"developer:label.scopes" = "Scopes"
"developer:label.guides_and_help" = "Guides & help"
"developer:label.secret_key" = "Secret key"
"developer:label.roll_key" = "Roll key"
"developer:label.data_usage" = "Data usage"
"developer:action.delete" = "Delete app"
"developer:action.authorize" = "Authorize"
@ -285,3 +302,9 @@ version = "1.0.0"
"littleweb:action.edit_site_name" = "Edit site name"
"littleweb:action.rename" = "Rename"
"littleweb:action.add" = "Add"
"marketplace:label.products" = "Products"
"marketplace:label.status" = "Status"
"marketplace:action.get_started" = "Get started"
"marketplace:action.finsh_setting_up_account" = "Finish setting up my account"
"marketplace:action.open_seller_dashboard" = "Open seller dashboard"

View file

@ -87,7 +87,10 @@ macro_rules! get_user_from_token {
{
Ok(ua) => {
if ua.permissions.check_banned() {
Some(tetratto_core::model::auth::User::banned())
let mut banned_user = tetratto_core::model::auth::User::banned();
banned_user.ban_reason = ua.ban_reason;
Some(banned_user)
} else {
Some(ua)
}
@ -109,7 +112,7 @@ macro_rules! get_user_from_token {
Ok((grant, ua)) => {
if grant.scopes.contains(&$grant_scope) {
if ua.permissions.check_banned() {
Some(tetratto_core::model::auth::User::banned())
None
} else {
Some(ua)
}
@ -140,6 +143,20 @@ macro_rules! get_user_from_token {
None
}
}};
(--browser_session=$browser_session:expr, $db:expr) => {{
// browser session id
match $db.get_user_by_browser_session(&$browser_session).await {
Ok(ua) => {
if ua.permissions.check_banned() {
None
} else {
Some(ua)
}
}
Err(_) => None,
}
}};
}
#[macro_export]
@ -175,6 +192,27 @@ macro_rules! user_banned {
#[macro_export]
macro_rules! check_user_blocked_or_private {
($user:expr, $other_user:ident, $data:ident, $jar:ident) => {
// check is_deactivated
if ($user.is_none() && $other_user.is_deactivated)
| ($user.is_some()
&& !$user
.as_ref()
.unwrap()
.permissions
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
&& $other_user.is_deactivated)
{
return Err(Html(
render_error(
Error::GeneralNotFound("user".to_string()),
&$jar,
&$data,
&$user,
)
.await,
));
}
// check require_account
if $user.is_none() && $other_user.settings.require_account {
return Err(Html(
@ -402,3 +440,17 @@ macro_rules! ignore_users_gen {
.concat()
};
}
#[macro_export]
macro_rules! get_app_from_key {
($db:ident, $headers:ident) => {
if let Some(token) = $headers.get("Atto-Secret-Key") {
match $db.get_app_by_api_key(token.to_str().unwrap()).await {
Ok(x) => Some(x),
Err(_) => None,
}
} else {
None
}
};
}

View file

@ -2,13 +2,18 @@
#![doc(html_favicon_url = "/public/favicon.svg")]
#![doc(html_logo_url = "/public/tetratto_bunny.webp")]
mod assets;
mod cookie;
mod image;
mod macros;
mod routes;
mod sanitize;
use assets::{init_dirs, write_assets};
use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji};
use stripe::Client as StripeClient;
use tetratto_core::model::{
permissions::{FinePermission, SecondaryPermission},
uploads::CustomEmoji,
};
pub use tetratto_core::*;
use axum::{
@ -27,15 +32,17 @@ use tracing::{Level, info};
use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc};
use tokio::sync::RwLock;
pub(crate) type State = Arc<RwLock<(DataManager, Tera, Client)>>;
pub(crate) type InnerState = (DataManager, Tera, Client, Option<StripeClient>);
pub(crate) type State = Arc<RwLock<InnerState>>;
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(value.as_str().unwrap()))
.replace("\\@", "@")
.replace("%5C@", "@")
.into(),
Ok(tetratto_shared::markdown::render_markdown(
&CustomEmoji::replace(value.as_str().unwrap()),
true,
)
.replace("\\@", "@")
.replace("%5C@", "@")
.into())
}
fn render_emojis(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
@ -53,6 +60,15 @@ fn check_supporter(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Va
.into())
}
fn check_dev_pass(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(
SecondaryPermission::from_bits(value.as_u64().unwrap() as u32)
.unwrap()
.check(SecondaryPermission::DEVELOPER_PASS)
.into(),
)
}
fn check_staff_badge(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(FinePermission::from_bits(value.as_u64().unwrap() as u32)
.unwrap()
@ -107,6 +123,7 @@ async fn main() {
tera.register_filter("markdown", render_markdown);
tera.register_filter("color", color_escape);
tera.register_filter("has_supporter", check_supporter);
tera.register_filter("has_dev_pass", check_dev_pass);
tera.register_filter("has_staff_badge", check_staff_badge);
tera.register_filter("has_banned", check_banned);
tera.register_filter("remove_script_tags", remove_script_tags);
@ -115,6 +132,13 @@ async fn main() {
let client = Client::new();
let mut app = Router::new();
// create stripe client
let stripe_client = if let Some(ref stripe) = config.stripe {
Some(StripeClient::new(stripe.secret.clone()))
} else {
None
};
// add correct routes
if var("LITTLEWEB").is_ok() {
app = app.merge(routes::lw_routes());
@ -123,13 +147,18 @@ 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' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; 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' blob: *; frame-ancestors 'self'"),
));
}
// add junk
app = app
.layer(Extension(Arc::new(RwLock::new((database, tera, client)))))
.layer(Extension(Arc::new(RwLock::new((
database,
tera,
client,
stripe_client,
)))))
.layer(axum::extract::DefaultBodyLimit::max(
var("BODY_LIMIT")
.unwrap_or("8388608".to_string())

View file

@ -404,7 +404,7 @@ select:focus {
.poll_bar {
background-color: var(--color-primary);
border-radius: var(--radius);
height: 25px;
height: 24px;
}
.poll_option {
@ -413,6 +413,22 @@ select:focus {
overflow-wrap: anywhere;
}
.progress_bar {
background: var(--color-super-lowered);
border-radius: var(--circle);
position: relative;
overflow: hidden;
height: 14px;
}
.progress_bar .poll_bar {
border-radius: var(--circle);
height: 14px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
position: absolute;
}
input[type="checkbox"] {
--color: #c9b1bc;
appearance: none;
@ -583,6 +599,9 @@ input[type="checkbox"]:checked {
border-radius: 6px;
height: max-content;
font-weight: 600;
display: flex;
justify-content: center;
align-items: center;
}
.notification.tr {
@ -597,6 +616,11 @@ input[type="checkbox"]:checked {
padding: 0;
}
.notification:not(.chip) .icon {
width: 100%;
height: 100%;
}
/* chip */
.chip {
background: var(--color-primary);
@ -931,7 +955,7 @@ dialog::backdrop {
transition: transform 0.15s;
}
.dropdown:has(.inner.open) .dropdown-arrow {
.dropdown:has(.inner.open) .dropdown_arrow {
transform: rotateZ(180deg);
}
@ -1111,7 +1135,7 @@ details[open] > summary {
margin-bottom: var(--pad-1);
}
details[open] > summary::after {
details[open]:not(.accordion) > summary::after {
top: 0;
left: 0;
width: 5px;
@ -1134,8 +1158,7 @@ details.accordion {
}
details.accordion summary {
background: var(--background);
border: solid 1px var(--color-super-lowered);
background: var(--color-lowered);
border-radius: var(--radius);
padding: var(--pad-3) var(--pad-4);
margin: 0;
@ -1143,11 +1166,15 @@ details.accordion summary {
user-select: none;
}
details.accordion summary .icon {
details.accordion summary:hover {
background: var(--color-super-lowered);
}
details.accordion summary .icon.dropdown_arrow {
transition: transform 0.15s;
}
details.accordion[open] summary .icon {
details.accordion[open] summary .icon.dropdown_arrow {
transform: rotateZ(180deg);
}
@ -1157,13 +1184,11 @@ details.accordion[open] summary {
}
details.accordion .inner {
background: var(--background);
background: var(--color-raised);
padding: var(--pad-3) var(--pad-4);
border-radius: var(--radius);
border-top-left-radius: 0;
border-top-right-radius: 0;
border: solid 1px var(--color-super-lowered);
border-top: none;
}
/* codemirror */

View file

@ -118,7 +118,7 @@
("class" "hidden lowered card w-full no_p_margin")
("ui_ident" "purchase_help")
(b (text "What does \"Purchase account\" mean?"))
(p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.supporter_price_text }}."))
(p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.price_texts.supporter }}."))
(p (text "Alternatively, you can provide an invite code to create your account for free.")))
(text "{%- endif %}")
(button

View file

@ -0,0 +1,25 @@
(text "{% extends \"auth/base.html\" %} {% block head %}")
(title
(text "Connection"))
(text "{% endblock %} {% block title %}Connection{% endblock %} {% block content %}")
(div
("class" "w-full flex-col gap-2")
("id" "status")
(b
(text "Working...")))
(text "{% if connection_type == \"refresh\" %}")
(script
("defer" "true")
(text "setTimeout(async () => {
trigger(\"seller::onboarding\");
}, 1000);"))
(text "{% elif connection_type == \"return\" %}")
(script
("defer" "true")
(text "setTimeout(async () => {
document.getElementById(\"status\").innerHTML =
`<b>Account updated.</b> You can now close this tab.`;
}, 1000);"))
(text "{%- endif %} {% endblock %}")

View file

@ -210,6 +210,30 @@
});
};
globalThis.mute_channel = async (id, mute = true) => {
await trigger(\"atto::debounce\", [\"channels::mute\"]);
fetch(`/api/v1/channels/${id}/mute`, {
method: mute ? \"POST\" : \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
if (mute) {
document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.add(\"hidden\");
document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.remove(\"hidden\");
} else {
document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.remove(\"hidden\");
document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.add(\"hidden\");
}
}
});
};
globalThis.update_channel_title = async (id) => {
await trigger(\"atto::debounce\", [\"channels::update_title\"]);
const title = await trigger(\"atto::prompt\", [\"New channel title:\"]);

View file

@ -31,6 +31,22 @@
(text "{{ icon \"user-plus\" }}")
(span
(text "{{ text \"chats:action.add_someone\" }}")))
; mute/unmute
(button
("class" "lowered small {% if channel.id in user.channel_mutes -%} hidden {%- endif %}")
("ui_ident" "channel.mute:{{ channel.id }}")
("onclick" "mute_channel('{{ channel.id }}')")
(icon (text "bell-off"))
(span
(str (text "chats:action.mute"))))
(button
("class" "lowered small {% if not channel.id in user.channel_mutes -%} hidden {%- endif %}")
("ui_ident" "channel.unmute:{{ channel.id }}")
("onclick" "mute_channel('{{ channel.id }}', false)")
(icon (text "bell-ring"))
(span
(str (text "chats:action.unmute"))))
; ...
(text "{%- endif %}")
(button
("class" "lowered small")

View file

@ -29,7 +29,6 @@
("minlength" "2")
("maxlength" "32")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}")
(div

View file

@ -39,7 +39,6 @@
("class" "flex gap-2")
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
(button
("class" "primary")
(text "{{ text \"requests:label.answer\" }}")))))
(text "{%- endif %}")
(div

View file

@ -28,7 +28,6 @@
("maxlength" "32")
("value" "{{ text }}")))
(button
("class" "primary")
(text "{{ text \"dialog:action.continue\" }}"))))
(div
("class" "card-nest")

View file

@ -135,7 +135,6 @@
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}"))))))
@ -190,7 +189,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w-content"))
(button
("class" "primary")
(text "{{ icon \"check\" }}"))))
(div
("class" "card-nest")
@ -213,7 +211,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp")
("class" "w-content"))
(button
("class" "primary")
(text "{{ icon \"check\" }}")))
(span
("class" "fade")
@ -245,7 +242,6 @@
("required" "")
("minlength" "18")))
(button
("class" "primary")
(text "{{ text \"communities:action.select\" }}")))))
(div
("class" "card flex flex-col gap-2 w-full")
@ -296,7 +292,6 @@
("minlength" "2")
("maxlength" "32")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(text "{% for channel in channels %}")
(div

View file

@ -102,13 +102,23 @@
("class" "flush")
("style" "font-weight: 600")
("target" "_top")
(text "{{ self::username(user=user) }}"))
(text "{% if user.permissions|has_banned -%}")
(del ("class" "fade") (text "{{ self::username(user=user) }}"))
(text "{% else %}")
(text "{{ self::username(user=user) }}")
(text "{%- endif %}"))
(text "{{ self::online_indicator(user=user) }} {% if user.is_verified -%}")
(span
("title" "Verified")
("style" "color: var(--color-primary)")
("class" "flex items-center")
(text "{{ icon \"badge-check\" }}"))
(text "{%- endif %} {% if user.permissions|has_staff_badge -%}")
(span
("title" "Staff")
("style" "color: var(--color-primary);")
("class" "flex items-center")
(text "{{ icon \"shield-user\" }}"))
(text "{%- endif %}"))
(text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}")
(div
@ -118,7 +128,7 @@
(div
("class" "card-nest post_outer:{{ post.id }} post_outer")
("is_repost" "{{ is_repost }}")
(text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}")
(text "{% if question -%} {{ self::question(question=question[0], owner=question[1], asking_about=question[2], profile=owner) }} {% else %}")
(div
("class" "card small")
(a
@ -173,6 +183,12 @@
("class" "flex items-center")
("style" "color: var(--color-primary)")
(text "{{ icon \"square-asterisk\" }}"))
(text "{%- endif %} {% if post.context.full_unlist -%}")
(span
("title" "Unlisted")
("class" "flex items-center")
("style" "color: var(--color-primary)")
(icon (text "eye-off")))
(text "{%- endif %} {% if post.stack -%}")
(a
("title" "Posted to a stack you're in")
@ -221,7 +237,7 @@
("hook" "long")
(text "{{ post.title }}"))
(button ("class" "small lowered") (icon (text "ellipsis"))))
(button ("title" "View post content") ("class" "small lowered") (icon (text "ellipsis"))))
(text "{% else %}")
(text "{% if not post.context.content_warning -%}")
(span
@ -315,13 +331,13 @@
("class" "button camo small")
("target" "_blank")
(text "{{ icon \"external-link\" }}"))
(text "{% if user -%}")
(div
("class" "dropdown")
(button
("class" "camo small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("title" "More options")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
@ -329,6 +345,7 @@
(b
("class" "title")
(text "{{ text \"general:label.share\" }}"))
(text "{% if user -%}")
(button
("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])")
(text "{{ icon \"repeat-2\" }}")
@ -351,7 +368,16 @@
(span
(text "BlueSky")))
(text "{%- endif %}")
(text "{% if user.id != post.owner -%}")
(text "{% if owner.settings.enable_questions -%}")
(a
("class" "button")
("href" "/@{{ owner.username }}?asking_about={{ post.id }}")
(icon (text "reply"))
(span
(str (text "communities:label.ask_about_this"))))
(text "{%- endif %}")
(text "{%- endif %}")
(text "{% if user and user.id != post.owner -%}")
(b
("class" "title")
(text "{{ text \"general:label.safety\" }}"))
@ -361,12 +387,12 @@
(text "{{ icon \"flag\" }}")
(span
(text "{{ text \"general:action.report\" }}")))
(text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}")
(text "{%- endif %} {% if user and (user.id == post.owner) or is_helper or can_manage_post %}")
(b
("class" "title")
(text "{{ text \"general:action.manage\" }}"))
; forge stuff
(text "{% if community and community.is_forge -%} {% if post.is_open -%}")
(text "{% if user and community and community.is_forge -%} {% if post.is_open -%}")
(button
("class" "green")
("onclick" "trigger('me::update_open', ['{{ post.id }}', false])")
@ -382,7 +408,7 @@
(text "{{ text \"forge:action.reopen\" }}")))
(text "{%- endif %} {%- endif %}")
; owner stuff
(text "{% if user.id == post.owner -%}")
(text "{% if user and user.id == post.owner -%}")
(a
("href" "/post/{{ post.id }}#/edit")
(text "{{ icon \"pen\" }}")
@ -414,8 +440,7 @@
(text "{{ icon \"undo\" }}")
(span
(text "{{ text \"general:action.restore\" }}")))
(text "{%- endif %} {%- endif %}")))
(text "{%- endif %}"))))
(text "{%- endif %} {%- endif %}"))))))
(text "{% if community and show_community and community.id != config.town_square or question %}"))
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
@ -427,7 +452,6 @@
("alt" "Image upload")
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])"))
(text "{% endfor %}"))
(text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}")
(div
("class" "w-full card-nest")
@ -624,7 +648,7 @@
--{{ css }}: {{ color|color }} !important;
}"))
(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}")
(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, asking_about=false, show_community=true, secondary=false, profile=false) -%}")
(div
("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2")
(text "{% if owner.id == 0 or question.context.mask_owner -%}")
@ -694,6 +718,10 @@
(text "{{ question.content|markdown|safe }}"))
; question drawings
(text "{{ self::post_media(upload_ids=question.drawings) }}")
; asking about
(text "{% if asking_about -%}")
(text "{{ self::post(post=asking_about[1], owner=asking_about[0], secondary=not secondary, show_community=false) }}")
(text "{%- endif %}")
; anonymous user ip thing
; this is only shown if the post author is anonymous AND we are a helper
(text "{% if is_helper and (owner.id == 0 or question.context.mask_owner) -%}")
@ -730,6 +758,7 @@
("class" "no_p_margin")
(text "{% if header -%} {{ header|markdown|safe }} {% else %} {{ text \"requests:label.ask_question\" }} {%- endif %}")))
(form
("id" "create_question_form")
("class" "card flex flex-col gap-2")
("onsubmit" "create_question_from_form(event)")
(div
@ -756,7 +785,6 @@
(div
("class" "flex gap-2")
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))
(text "{% if drawing_enabled -%}")
@ -800,7 +828,7 @@
}"))
(text "{%- endif %}"))
(text "{% if not is_global and allow_anonymous and not user -%}")
(text "{% if not is_global and allow_anonymous and user -%}")
(div
("class" "flex gap-2 items-center")
(input
@ -816,6 +844,15 @@
(script
(text "globalThis.gerald = null;
// asking about
globalThis.asking_about = new URLSearchParams(window.location.search).get(\"asking_about\") || \"\";
if (asking_about) {
document.getElementById(\"create_question_form\").innerHTML +=
`<hr /><span class=\"fade\">Asking about: <a href=\"/post/${asking_about}\" target=\"_blank\">${asking_about}</a> <a href=\"?\" class=\"red\">(cancel)</a></span>`;
}
// ...
async function create_question_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"questions::create\"]);
@ -837,7 +874,8 @@
receiver: \"{{ receiver }}\",
community: \"{{ community }}\",
is_global: \"{{ is_global }}\" == \"true\",
mask_owner: (e.target.mask_owner || { checked:false }).checked
mask_owner: (e.target.mask_owner || { checked:false }).checked,
asking_about,
}),
);
@ -866,7 +904,7 @@
(text "{%- endmacro %} {% macro global_question(question, can_manage_questions=false, secondary=false, show_community=true) -%}")
(div
("class" "card-nest")
(text "{{ self::question(question=question[0], owner=question[1], show_community=show_community) }}")
(text "{{ self::question(question=question[0], owner=question[1], asking_about=false, show_community=show_community) }}")
(div
("class" "small card flex justify-between flex-wrap gap-2{% if secondary -%} secondary{%- endif %}")
(div
@ -893,6 +931,7 @@
("class" "camo small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("title" "More options")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
@ -1006,6 +1045,7 @@
("class" "camo small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("title" "More options")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
@ -1108,16 +1148,6 @@
(text "{{ icon \"circle-user-round\" }}")
(span
(text "{{ text \"auth:link.my_profile\" }}")))
(a
("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")
@ -1244,6 +1274,7 @@
("class" "camo small square")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("title" "More options")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
@ -1427,7 +1458,9 @@
});
})();"))
(text "{%- endmacro %} {% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}")
(text "{%- endmacro %}")
(text "{% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}")
(div
("class" "card w-full supporter_ad")
("ui_ident" "supporter_ad")
@ -1447,8 +1480,9 @@
(text "{{ icon \"heart\" }}")
(span
(text "{{ text \"general:action.become_supporter\" }}")))))
(text "{%- endif %} {%- endmacro %}")
(text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}")
(text "{% macro create_post_options() -%}")
(div
("class" "flex gap-2 flex-wrap")
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if not quoting -%} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %} {%- endif %}")
@ -1465,6 +1499,7 @@
("title" "More options")
("onclick" "document.getElementById('post_options_dialog').showModal()")
("type" "button")
("title" "More options")
(text "{{ icon \"ellipsis\" }}"))
(label
@ -1507,6 +1542,7 @@
is_nsfw: false,
content_warning: \"\",
tags: [],
full_unlist: false,
};
window.BLANK_INITIAL_SETTINGS = JSON.stringify(
@ -1543,6 +1579,11 @@
// window.POST_INITIAL_SETTINGS.is_nsfw.toString(),
// \"checkbox\",
// ],
[
[\"full_unlist\", \"Unlist from timelines\"],
window.POST_INITIAL_SETTINGS.full_unlist.toString(),
\"checkbox\",
],
[
[\"content_warning\", \"Content warning\"],
window.POST_INITIAL_SETTINGS.content_warning,
@ -1759,8 +1800,8 @@
(span ("class" "notification chip") (text "{{ total }} votes"))
(text "{% if not poll[2] -%}")
(span
("class" "notification chip")
(text "Expires in ")
("class" "notification chip flex items-center gap-1")
(text "Expires in")
(span
("class" "poll_date")
("data-created" "{{ poll[0].created }}")
@ -1836,7 +1877,6 @@
("id" "join_or_leave")
(text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}")
(button
("class" "primary")
("onclick" "join_community()")
(text "{{ icon \"circle-plus\" }}")
(span
@ -2050,6 +2090,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("style" "width: 32px")
("title" "More options")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
@ -2076,6 +2117,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("style" "width: 32px")
("title" "More options")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
@ -2167,6 +2209,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("style" "width: 32px")
("title" "More options")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
@ -2246,6 +2289,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("style" "width: 32px")
("title" "More options")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
@ -2323,10 +2367,6 @@
(text "Save infinite post drafts"))
(li
(text "Ability to search through all posts"))
(li
(text "Ability to create forges"))
(li
(text "Create more than 1 app"))
(li
(text "Create up to 10 stack blocks"))
(li
@ -2350,18 +2390,16 @@
(sup (a ("href" "#footnote-1") (text "1"))))
(text "{%- endif %}"))
(a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}")
("class" "button")
("target" "_blank")
(text "Become a supporter ({{ config.stripe.supporter_price_text }})"))
(text "Become a supporter ({{ config.stripe.price_texts.supporter }})"))
(span
("class" "fade")
(text "Please use your")
(b
(text " real email "))
(text "when
completing payment. It is required to manage
your billing settings."))
(text "when completing payment. It is required to manage your billing settings."))
(text "{% if config.security.enable_invite_codes -%}")
(span
@ -2370,3 +2408,46 @@
(b (text "1: ")) (text "After your account is at least 1 month old"))
(text "{%- endif %}")
(text "{%- endmacro %}")
(text "{% macro get_developer_pass_button() -%}")
(p
(text "You currently do not hold a developer pass. With a developer pass, you'll get:"))
(ul
("style" "margin-bottom: var(--pad-4)")
(li
(text "Increased app storage limit (500 KB->25 MB)"))
(li
(text "Ability to create forges"))
(li
(text "Ability to create more than 1 app"))
(li
(text "Developer pass profile badge")))
(a
("href" "{{ config.stripe.payment_links.dev_pass }}?client_reference_id={{ user.id }}")
("class" "button")
("target" "_blank")
(text "Continue ({{ config.stripe.price_texts.dev_pass }})"))
(span
("class" "fade")
(text "Please use your")
(b
(text " real email "))
(text "when completing payment. It is required to manage your billing settings. If you're already a supporter, please use the same email you used there."))
(text "{%- endmacro %}")
(text "{% macro developer_pass_ad(body) -%} {% if config.stripe and not has_developer_pass %}")
(div
("class" "card w-full supporter_ad")
("ui_ident" "supporter_ad")
("onclick" "window.location.href = '/settings#/account/billing'")
(div
("class" "card w-full flex flex-wrap items-center gap-2 justify-between")
(b
(text "{{ body }}"))
(a
("href" "/settings#/account/billing")
("class" "button small")
(icon (text "arrow-right"))
(span
(str (text "dialog:action.continue"))))))
(text "{%- endif %} {%- endmacro %}")

View file

@ -10,11 +10,27 @@
(div
("id" "manage_fields")
("class" "card lowered flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(icon (text "database"))
(b (str (text "developer:label.data_usage"))))
(div
("class" "card flex flex-col gap-2")
(p ("class" "fade") (text "App data keys are not included in this metric, only stored values count towards your limit."))
(text "{% set percentage = (app.data_used / data_limit) * 100 %}")
(div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%")))
(div
("class" "w-full flex justify-between items-center")
(span (text "{{ app.data_used|filesizeformat }}"))
(span (text "{{ data_limit|filesizeformat }}")))))
(text "{% if is_helper -%}")
(div
("class" "card-nest")
(div
("class" "card small")
("class" "card small flex items-center gap-2")
(icon (text "infinity"))
(b (str (text "developer:label.change_quota_status"))))
(div
("class" "card")
@ -28,11 +44,34 @@
("value" "Unlimited")
("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}")
(text "Unlimited")))))
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(icon (text "database-zap"))
(b (str (text "developer:label.change_storage_capacity"))))
(div
("class" "card")
(select
("onchange" "save_storage_capacity(event)")
(option
("value" "Tier1")
("selected" "{% if app.storage_capacity == 'Tier1' -%}true{% else %}false{%- endif %}")
(text "Tier 1 (25 MB)"))
(option
("value" "Tier2")
("selected" "{% if app.storage_capacity == 'Tier2' -%}true{% else %}false{%- endif %}")
(text "Tier 2 (50 MB)"))
(option
("value" "Tier3")
("selected" "{% if app.storage_capacity == 'Tier3' -%}true{% else %}false{%- endif %}")
(text "Tier 3 (100 MB)")))))
(text "{%- endif %}")
(div
("class" "card-nest")
(div
("class" "card small")
("class" "card small flex items-center gap-2")
(icon (text "pencil"))
(b (str (text "developer:label.change_title"))))
(form
("class" "card flex flex-col gap-2")
@ -50,14 +89,14 @@
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))))
(div
("class" "card-nest")
(div
("class" "card small")
("class" "card small flex items-center gap-2")
(icon (text "house"))
(b (str (text "developer:label.change_homepage"))))
(form
("class" "card flex flex-col gap-2")
@ -75,14 +114,14 @@
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))))
(div
("class" "card-nest")
(div
("class" "card small")
("class" "card small flex items-center gap-2")
(icon (text "goal"))
(b (str (text "developer:label.change_redirect"))))
(form
("class" "card flex flex-col gap-2")
@ -100,14 +139,14 @@
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))))
(div
("class" "card-nest")
(div
("class" "card small")
("class" "card small flex items-center gap-2")
(icon (text "telescope"))
(b (str (text "developer:label.manage_scopes"))))
(form
("class" "card flex flex-col gap-2")
@ -140,10 +179,22 @@
(icon (text "external-link")) (text "Docs"))))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}"))))))
(text "{{ text \"general:action.save\" }}")))))
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(icon (text "rotate-ccw-key"))
(b (str (text "developer:label.secret_key"))))
(div
("class" "card flex flex-col gap-2")
(p ("class" "fade") (text "Your app's API key can only be seen once, so don't lose it. Rolling the key will invalidate the old one."))
(pre (code ("id" "new_key")))
(button
("onclick" "roll_key()")
(str (text "developer:label.roll_key"))))))
(div
("class" "card flex flex-col gap-2")
(ul
@ -151,7 +202,8 @@
(li (b (text "Redirect URL: ")) (text "{{ app.redirect }}"))
(li (b (text "Quota status: ")) (text "{{ app.quota_status }}"))
(li (b (text "User grants: ")) (text "{{ app.grants }}"))
(li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}")))
(li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}"))
(li (b (text "App ID (for SDK): ")) (text "{{ app.id }}")))
(a
("class" "button")
@ -202,6 +254,26 @@
});
};
globalThis.save_storage_capacity = (event) => {
const selected = event.target.selectedOptions[0];
fetch(\"/api/v1/apps/{{ app.id }}/storage_capacity\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
storage_capacity: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.change_title = async (e) => {
e.preventDefault();
@ -323,6 +395,31 @@
});
};
globalThis.roll_key = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/apps/{{ app.id }}/roll\", {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
document.getElementById(\"new_key\").innerText = res.payload;
}
});
};
globalThis.delete_app = async () => {
if (
!(await trigger(\"atto::confirm\", [

View file

@ -41,23 +41,19 @@
("id" "homepage")
("placeholder" "homepage")
("required" "")
("minlength" "2")
("maxlength" "32")))
("minlength" "2")))
(div
("class" "flex flex-col gap-1")
(label
("for" "title")
(text "{{ text \"developer:label.redirect\" }}"))
(text "{{ text \"developer:label.redirect\" }} (optional)"))
(input
("type" "url")
("name" "redirect")
("id" "redirect")
("placeholder" "redirect URL")
("required" "")
("minlength" "2")
("maxlength" "32")))
("minlength" "2")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
; app listing
@ -126,7 +122,7 @@
body: JSON.stringify({
title: e.target.title.value,
homepage: e.target.homepage.value,
redirect: e.target.redirect.value,
redirect: e.target.redirect.value || \"\",
}),
})
.then((res) => res.json())

View file

@ -39,6 +39,13 @@
(str (text "dialog:action.cancel")))))))
(script
(text "setTimeout(() => {
// {% if app.redirect|length == 0 %}
alert(\"App has an invalid redirect. Please contact the owner for help.\");
window.close();
return;
// {% endif %}
// ...
globalThis.authorize = async (event) => {
if (
!(await trigger(\"atto::confirm\", [
@ -76,6 +83,7 @@
const search = new URLSearchParams(window.location.search);
search.append(\"verifier\", verifier);
search.append(\"token\", res.payload);
search.append(\"uid\", \"{{ user.id }}\");
window.location.href = `{{ app.redirect|remove_script_tags|safe }}?${search.toString()}`;
}

View file

@ -6,7 +6,7 @@
(main
("class" "flex flex-col gap-2")
; create new
(text "{% if user.permissions|has_supporter -%}")
(text "{% if user.secondary_permissions|has_dev_pass -%}")
(div
("class" "card-nest")
(div
@ -30,10 +30,9 @@
("minlength" "2")
("maxlength" "32")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(text "{% else %}")
(text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}")
(text "{{ components::developer_pass_ad(body=\"Get a developer pass to create forges!\") }}")
(text "{%- endif %}")
; forge listing

View file

@ -253,7 +253,6 @@
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))))))
@ -379,7 +378,6 @@
("name" "tags")
("id" "tags")
("placeholder" "tags")
("required" "")
("minlength" "2")
("maxlength" "128")
(text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}"))

View file

@ -32,7 +32,7 @@
(input
("type" "uri")
("class" "w-full")
("true_value" "{{ path }}")
("true_value" "")
("name" "uri")
("id" "uri"))
@ -47,7 +47,7 @@
("exclude" "dropdown")
("style" "gap: var(--pad-1) !important")
(text "{{ components::avatar(username=user.username, size=\"24px\") }}")
(icon_class (text "chevron-down") (text "dropdown-arrow")))
(icon_class (text "chevron-down") (text "dropdown_arrow")))
(text "{{ components::user_menu() }}"))
(text "{%- endif %}"))
@ -55,7 +55,7 @@
(iframe
("id" "browser_iframe")
("frameborder" "0")
("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }} {%- endif %}"))
("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }}?s={{ session }} {%- endif %}"))
(style
("data-turbo-temporary" "true")
@ -91,6 +91,7 @@
position: fixed;
width: calc(100dvw - (62px + var(--pad-2) * 2)) !important;
left: var(--pad-2);
z-index: 2;
}
}
@ -101,7 +102,7 @@
height: var(--h);
min-height: var(--h);
max-height: var(--h);
font-size: 14px;
font-size: 16px;
}
#panel button:not(.inner *),
@ -115,18 +116,23 @@
}"))
(script
(text "function littleweb_navigate(uri) {
(text "globalThis.SECRET_SESSION = \"{{ session }}\";
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\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}?s={{ session }}`;
if (!uri.includes(\"atto://\")) {
document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`);
} else {
document.getElementById(\"uri\").setAttribute(\"true_value\", uri);
}
document.getElementById(\"uri\").value = uri.replace(\"atto://\", \"\").split(\"/\")[0];
}
document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => {
@ -151,7 +157,14 @@
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);
if (!uri.includes(\"atto://\")) {
document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`);
} else {
document.getElementById(\"uri\").setAttribute(\"true_value\", uri);
}
document.getElementById(\"uri\").value = uri.replace(\"atto://\", \"\").split(\"/\")[0];
}
});
@ -206,6 +219,9 @@
is_focused = false;
});
document.getElementById(\"uri\").value = document.getElementById(\"uri\").getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]"))
// navigate
if ({{ path|length }} > 0) {
littleweb_navigate(\"{{ path|safe }}\");
}"))
(text "{% endblock %}")

View file

@ -72,7 +72,7 @@
("class" "card hidden w-full lowered flex flex-col gap-2")
("onsubmit" "add_data_from_form(event)")
(div
("class" "flex gap-2")
("class" "flex gap-2 flex-collapse")
(div
("class" "flex w-full flex-col gap-1")
(label
@ -119,44 +119,48 @@
(icon (text "check"))
(str (text "general:action.save")))))
; data
(table
(thead
(tr
(th (text "Name"))
(th (text "Type"))
(th (text "Value"))
(th (text "Actions"))))
(div
("class" "w-full")
("style" "max-width: 100%; overflow: auto; min-height: 512px")
(table
("class" "w-full")
(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")))
(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" "inner")
("class" "dropdown")
(button
("onclick" "rename_data('{{ item[0] }}')")
(icon (text "pencil"))
(str (text "littleweb:action.rename")))
("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 %}"))))))
(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

View file

@ -5,6 +5,17 @@
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
; viewing other user's domains warning
(text "{% if profile.id != user.id -%}")
(div
("class" "card w-full red flex gap-2 items-center")
(text "{{ icon \"skull\" }}")
(b
(text "Viewing other user's domains! Please be careful.")))
(text "{%- endif %}")
; ...
(text "{% if user -%}")
(div
("class" "pillmenu")
@ -48,7 +59,6 @@
(option ("value" "{{ tld }}") (text ".{{ tld|lower }}"))
(text "{%- endfor %}")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))
(details

View file

@ -14,9 +14,24 @@
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "{{ service.name }}")))
("class" "card small flex flex-col gap-2")
(div
("class" "flex w-full gap-2 justify-between")
(b
(text "{{ service.name }}"))
(button
("class" "small lowered")
("title" "Help")
("onclick" "document.getElementById('site_help').classList.toggle('hidden')")
(icon (text "circle-question-mark"))))
(div
("class" "card w-full lowered flex flex-col gap-2 hidden no_p_margin")
("id" "site_help")
(p (text "Your site should include an \"index.html\" file in order to show content on its homepage."))
(p (text "In the HTML editor, you can type `!` and use the provided suggestion to get an HTML boilerplate."))
(p (text "After you've created a page, you can click \"Copy ID\" and go to manage a domain you own. On the domain management page, click \"Add\" and paste the ID you copied into the value field."))))
(div
("class" "flex gap-2 flex-wrap card")
@ -72,53 +87,57 @@
("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"))))
(div
("class" "w-full")
("style" "max-width: 100%; overflow: auto; min-height: 512px")
(table
("class" "w-full")
(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 %}")
(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")))
(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" "inner")
("class" "dropdown")
(button
("onclick" "rename_file('{{ item.id }}')")
(icon (text "pencil"))
(str (text "littleweb:action.rename")))
("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 %}")))
(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"))
@ -319,6 +338,7 @@
(text "{% if file and file.mime != 'Plain' -%}")
(script ("src" "https://unpkg.com/monaco-editor@0.52.2/min/vs/loader.js"))
(script ("src" "https://unpkg.com/emmet-monaco-es/dist/emmet-monaco.min.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\" } });
@ -337,10 +357,16 @@
style.innerText = '@import \"https://unpkg.com/monaco-editor@0.52.2/min/vs/editor/editor.main.css\";';
shadow.appendChild(style);
emmetMonaco.emmetHTML();
emmetMonaco.emmetCSS();
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\",
suggest: {
snippetsPreventQuickSuggestions: false,
},
});
});"))
(text "{%- endif %}")

View file

@ -5,6 +5,17 @@
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
; viewing other user's services warning
(text "{% if profile.id != user.id -%}")
(div
("class" "card w-full red flex gap-2 items-center")
(text "{{ icon \"skull\" }}")
(b
(text "Viewing other user's sites! Please be careful.")))
(text "{%- endif %}")
; ...
(text "{% if user -%}")
(div
("class" "pillmenu")
@ -34,7 +45,6 @@
("minlength" "2")
("maxlength" "32")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(text "{%- endif %}")
(div
@ -62,7 +72,10 @@
(span
("class" "date")
(text "{{ item.created }}"))
(text "; {{ item.files|length }} files")))
(text "; Updated ")
(span
("class" "date")
(text "{{ item.revision }}"))))
(text "{% endfor %}"))))
(script

View file

@ -39,12 +39,6 @@
("title" "Create post")
(icon (text "square-pen")))
(a
("href" "/chats/0/0")
("class" "button {% if selected == 'chats' -%}active{%- endif %}")
("title" "Chats")
(icon (text "message-circle")))
(a
("href" "/requests")
("class" "button {% if selected == 'requests' -%}active{%- endif %}")
@ -65,6 +59,43 @@
("id" "notifications_span")
(text "{{ user.notification_count }}")))
(text "{% if user -%}")
(div
("class" "dropdown")
(button
("class" "flex-row {% if selected == 'chats' or selected == 'journals' -%}active{%- endif %}")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("title" "More services")
(icon (text "grip")))
(div
("class" "inner")
(a
("href" "/chats/0/0")
("title" "Chats")
(icon (text "message-circle"))
(str (text "communities:label.chats")))
(a
("href" "/journals/0/0")
(icon (text "notebook"))
(str (text "general:link.journals")))
(a
("href" "/forges")
(icon (text "anvil"))
(str (text "forge:label.forges")))
(a
("href" "/developer")
(icon (text "code"))
(str (text "developer:label.apps")))
(text "{% if config.lw_host -%}")
(button
("onclick" "document.getElementById('littleweb').showModal()")
(icon (text "globe"))
(str (text "general:link.little_web")))
(text "{%- endif %}")))
(text "{%- endif %}")
(text "{% if not hide_user_menu -%}")
(div
("class" "dropdown")
@ -73,8 +104,9 @@
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("style" "gap: var(--pad-1) !important")
("title" "Account options")
(text "{{ components::avatar(username=user.username, size=\"24px\") }}")
(icon_class (text "chevron-down") (text "dropdown-arrow")))
(icon_class (text "chevron-down") (text "dropdown_arrow")))
(text "{{ components::user_menu() }}"))
(text "{%- endif %} {% else %}")
@ -84,7 +116,7 @@
("class" "title")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
(icon_class (text "chevron-down") (text "dropdown-arrow")))
(icon_class (text "chevron-down") (text "dropdown_arrow")))
(div
("class" "inner")
@ -331,3 +363,17 @@
(span
(text "{{ text \"settings:tab.connections\" }}")))
(text "{%- endmacro %}")
(text "{% macro seller_settings_nav_options() -%}")
(a
("data-tab-button" "account")
("class" "active")
("href" "#/account")
(icon (text "smile"))
(span (str (text "settings:tab.account"))))
(a
("data-tab-button" "products")
("href" "#/products")
(icon (text "package"))
(span (str (text "marketplace:label.products"))))
(text "{%- endmacro %}")

View file

@ -0,0 +1,79 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Seller settings - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
; nav
(div
("class" "mobile_nav mobile")
; primary nav
(div
("class" "dropdown")
("style" "width: max-content")
(button
("class" "raised small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
(icon (text "sliders-horizontal"))
(span ("class" "current_tab_text") (text "account")))
(div
("class" "inner left")
(text "{{ macros::seller_settings_nav_options() }}"))))
; nav desktop
(div
("class" "desktop pillmenu")
(text "{{ macros::seller_settings_nav_options() }}"))
; ...
(div
("class" "card w-full lowered flex flex-col gap-2")
("data-tab" "account")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(div
("class" "notification")
("style" "width: 46px")
(icon (text "stripe")))
(b (str (text "marketplace:label.status"))))
(div
("class" "card")
(text "{% if user.seller_data.account_id -%}")
(text "{% if user.seller_data.completed_onboarding -%}")
; completed onboarding + has stripe account linked
(button
("onclick" "trigger('seller::login')")
(icon (text "arrow-right"))
(str (text "marketplace:action.open_seller_dashboard")))
(text "{% else %}")
; not completed onboarding
(p (text "You've not finished setting up your Stripe account."))
(p (text "Please complete onboarding to accept payments."))
(button
("onclick" "trigger('seller::onboarding')")
(icon (text "arrow-right"))
(str (text "marketplace:action.finsh_setting_up_account")))
(text "{%- endif %}")
(text "{% else %}")
; doesn't have a stripe account linked
(button
("onclick" "trigger('seller::register')")
(icon (text "arrow-right"))
(str (text "marketplace:action.get_started")))
(text "{%- endif %}"))))
(div
("class" "card w-full lowered hidden flex flex-col gap-2")
("data-tab" "products")
(div
("class" "card w-full flex flex-wrap gap-2")
)))
(text "{% endblock %}")

View file

@ -17,7 +17,7 @@
(p (text "You'll find out what each achievement is when you get it, so look around!"))
(hr)
(span (b (text "Your progress: ")) (text "{{ percentage|round(method=\"floor\", precision=2) }}%"))
(div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))))
(div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%")))))
(div
("class" "card-nest")

View file

@ -62,12 +62,15 @@
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"user-plus\" }}")
(a
("href" "/api/v1/auth/user/find/{{ request.id }}")
(text "{{ components::avatar(username=request.id, selector_type=\"id\") }}"))
(span
(text "{{ text \"requests:label.user_follow_request\" }}")))
(div
("class" "card flex flex-col gap-2")
(span
("class" "flex items-center gap-2")
(text "{{ text \"requests:label.user_follow_request_message\" }}"))
(div
("class" "card flex flex-wrap w-full secondary gap-2")
@ -92,7 +95,7 @@
(text "{%- endif %} {% endfor %} {% for question in questions %}")
(div
("class" "card-nest")
(text "{{ components::question(question=question[0], owner=question[1], profile=user) }}")
(text "{{ components::question(question=question[0], owner=question[1], asking_about=question[2], profile=user) }}")
(form
("class" "card flex flex-col gap-2")
("onsubmit" "answer_question_from_form(event, '{{ question[0].id }}')")
@ -129,7 +132,6 @@
(text "{{ text \"auth:action.ip_block\" }}")))
(button
("class" "primary")
(text "{{ text \"requests:label.answer\" }}")))))
(text "{% endfor %}")))

View file

@ -28,7 +28,6 @@
("required" "")
("minlength" "16")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))))
(script

View file

@ -50,6 +50,18 @@
(span
("class" "notification")
(text "{{ profile.request_count }}")))
(a
("href" "/services?id={{ profile.id }}")
("class" "button lowered")
(icon (text "globe"))
(span
(text "Sites")))
(a
("href" "/domains?id={{ profile.id }}")
("class" "button lowered")
(icon (text "globe"))
(span
(text "Domains")))
(button
("class" "red lowered")
("onclick" "delete_account(event)")
@ -72,7 +84,7 @@
const ui = await ns(\"ui\");
const element = document.getElementById(\"mod_options\");
async function profile_request(do_confirm, path, body) {
globalThis.profile_request = async (do_confirm, path, body) => {
if (do_confirm) {
if (
!(await trigger(\"atto::confirm\", [
@ -155,6 +167,33 @@
});
};
globalThis.update_user_secondary_role = async (new_role) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/auth/user/{{ profile.id }}/role/2`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
role: Number.parseInt(new_role),
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
ui.refresh_container(element, [\"actions\"]);
setTimeout(() => {
@ -173,11 +212,21 @@
\"{{ profile.awaiting_purchase }}\",
\"checkbox\",
],
[
[\"is_deactivated\", \"Is deactivated\"],
\"{{ profile.is_deactivated }}\",
\"checkbox\",
],
[
[\"role\", \"Permission level\"],
\"{{ profile.permissions }}\",
\"input\",
],
[
[\"secondary_role\", \"Secondary permission level\"],
\"{{ profile.secondary_permissions }}\",
\"input\",
],
],
null,
{
@ -191,9 +240,17 @@
awaiting_purchase: value,
});
},
is_deactivated: (value) => {
profile_request(false, \"deactivated\", {
is_deactivated: value,
});
},
role: (new_role) => {
return update_user_role(new_role);
},
secondary_role: (new_role) => {
return update_user_secondary_role(new_role);
},
},
);
}, 100);
@ -226,6 +283,32 @@
("class" "card lowered flex flex-wrap gap-2")
(text "{{ components::user_plate(user=invite[0], show_menu=false) }}")))
(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 "scale"))
(span
(str (text "mod_panel:label.ban_reason")))))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "event.preventDefault(); profile_request(false, 'ban_reason', { reason: event.target.reason.value || '' })")
(div
("class" "flex flex-col gap-1")
(label
("for" "title")
(str (text "mod_panel:label.ban_reason")))
(textarea
("type" "text")
("name" "reason")
("id" "reason")
("placeholder" "ban reason")
("minlength" "2")
(text "{{ profile.ban_reason|remove_script_tags|safe }}")))
(button
(str (text "general:action.save")))))
(div
("class" "card-nest w-full")
(div
@ -244,6 +327,24 @@
(div
("class" "card lowered flex flex-col gap-2")
("id" "permission_builder")))
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ icon \"blocks\" }}")
(span
(text "{{ text \"mod_panel:label.permissions_level_builder\" }}")))
(button
("class" "small lowered")
("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('secondary_role').value))")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}"))))
(div
("class" "card lowered flex flex-col gap-2")
("id" "secondary_permission_builder")))
(script
(text "setTimeout(async () => {
const get_permissions_html = await trigger(
@ -291,6 +392,33 @@
Number.parseInt(\"{{ profile.permissions }}\"),
\"permission_builder\",
);
}, 250);
setTimeout(async () => {
const get_permissions_html = await trigger(
\"ui::generate_permissions_ui\",
[
{
// https://trisuaso.github.io/tetratto/tetratto/model/permissions/struct.SecondaryPermission.html
DEFAULT: 1 << 0,
ADMINISTRATOR: 1 << 1,
MANAGE_DOMAINS: 1 << 2,
MANAGE_SERVICES: 1 << 3,
MANAGE_PRODUCTS: 1 << 4,
DEVELOPER_PASS: 1 << 5,
MANAGE_LETTERS: 1 << 6,
},
\"secondary_role\",
\"add_permission_to_secondary_role\",
\"remove_permission_to_secondary_role\",
],
);
document.getElementById(\"secondary_permission_builder\").innerHTML =
get_permissions_html(
Number.parseInt(\"{{ profile.secondary_permissions }}\"),
\"secondary_permission_builder\",
);
}, 250);")))
(text "{% endblock %}")

View file

@ -37,7 +37,6 @@
("minlength" "2")
("maxlength" "4096")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(div
("class" "card-nest")

View file

@ -71,7 +71,6 @@
("name" "content")
("id" "content")
("placeholder" "content")
("required" "")
("minlength" "2")
("maxlength" "4096")))
(div
@ -81,7 +80,6 @@
("class" "flex gap-2")
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))))
(text "{%- endif %}")
(div
@ -125,7 +123,6 @@
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"communities:action.configure\" }}"))))
(text "{%- endif %}")
(div
("class" "flex flex-col gap-2 hidden")
("data-tab" "configure")
@ -201,7 +198,7 @@
\"checkbox\",
],
[
[\"is_nsfw\", \"Hide from public timelines\"],
[\"is_nsfw\", \"Mark as NSFW\"],
\"{{ community.context.is_nsfw }}\",
\"checkbox\",
],
@ -210,6 +207,11 @@
settings.content_warning,
\"textarea\",
],
[
[\"full_unlist\", \"Unlist from timelines\"],
\"{{ user.settings.auto_full_unlist }}\",
\"checkbox\",
],
[
[\"tags\", \"Tags\"],
settings.tags.join(\", \"),
@ -245,6 +247,7 @@
},
});
}, 250);")))
(text "{%- endif %}")
(text "{% if user and user.id == post.owner -%}")
(div
("class" "card-nest w-full hidden")
@ -275,7 +278,6 @@
("class" "flex gap-2")
(text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}")
(button
("class" "primary")
(text "{{ text \"general:action.save\" }}")))))
(script
(text "async function edit_post_from_form(e) {

View file

@ -72,19 +72,25 @@
("style" "color: var(--color-primary);")
("class" "flex items-center")
(text "{{ icon \"badge-check\" }}"))
(text "{%- endif %} {% if profile.permissions|has_supporter -%}")
(text "{%- endif %} {% if profile.permissions|has_supporter -%}")
(span
("title" "Supporter")
("style" "color: var(--color-primary);")
("class" "flex items-center")
(text "{{ icon \"star\" }}"))
(text "{%- endif %} {% if profile.permissions|has_staff_badge -%}")
(text "{%- endif %} {% if profile.secondary_permissions|has_dev_pass -%}")
(span
("title" "Developer pass")
("style" "color: var(--color-primary);")
("class" "flex items-center")
(text "{{ icon \"id-card-lanyard\" }}"))
(text "{%- endif %} {% if profile.permissions|has_staff_badge -%}")
(span
("title" "Staff")
("style" "color: var(--color-primary);")
("class" "flex items-center")
(text "{{ icon \"shield-user\" }}"))
(text "{%- endif %} {% if profile.permissions|has_banned -%}")
(text "{%- endif %} {% if profile.permissions|has_banned -%}")
(span
("title" "Banned")
("style" "color: var(--color-primary);")
@ -101,6 +107,7 @@
(p
(text "{{ profile.settings.status }}"))
(text "{%- endif %}")
(text "{% if not profile.settings.hide_social_follows or (user and user.id == profile.id) -%}")
(div
("class" "w-full flex")
(a
@ -117,6 +124,7 @@
(text "{{ profile.following_count }}"))
(span
(text "{{ text \"auth:label.following\" }}"))))
(text "{%- endif %}")
(text "{% if is_following_you -%}")
(b
("class" "notification chip w-content flex items-center gap-2")
@ -225,7 +233,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("class" "lowered red")
(icon_class (text "chevron-down") (text "dropdown-arrow"))
(icon_class (text "chevron-down") (text "dropdown_arrow"))
(str (text "auth:action.block")))
(div
("class" "inner left")
@ -290,7 +298,7 @@
]);
fetch(
\"/api/v1/auth/user/{{ profile.id }}/follow\",
\"/api/v1/auth/user/{{ profile.id }}/follow/toggle\",
{
method: \"POST\",
},

View file

@ -30,7 +30,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("class" "lowered red")
(icon_class (text "chevron-down") (text "dropdown-arrow"))
(icon_class (text "chevron-down") (text "dropdown_arrow"))
(str (text "auth:action.block")))
(div
("class" "inner left")

View file

@ -20,7 +20,11 @@
(div
("class" "card flex flex-col gap-2")
(span
("class" "fade")
(text "{{ text \"auth:label.private_profile_message\" }}"))
(span
("class" "no_p_margin")
(text "{{ profile.settings.private_biography|markdown|safe }}"))
(div
("class" "card w-full secondary flex gap-2")
(text "{% if user -%} {% if not is_following -%}")
@ -31,6 +35,7 @@
(text "{{ icon \"user-plus\" }}")
(span
(text "{{ text \"auth:action.request_to_follow\" }}")))
(text "{% if follow_requested -%}")
(button
("onclick" "cancel_follow_user(event)")
("class" "lowered red{% if not follow_requested -%} hidden{%- endif %}")
@ -38,7 +43,7 @@
(text "{{ icon \"user-minus\" }}")
(span
(text "{{ text \"auth:action.cancel_follow_request\" }}")))
(text "{% else %}")
(text "{%- endif %} {% else %}")
(button
("onclick" "toggle_follow_user(event)")
("class" "lowered red")
@ -53,7 +58,7 @@
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("class" "lowered red")
(icon_class (text "chevron-down") (text "dropdown-arrow"))
(icon_class (text "chevron-down") (text "dropdown_arrow"))
(str (text "auth:action.block")))
(div
("class" "inner left")
@ -76,7 +81,7 @@
(script
(text "globalThis.toggle_follow_user = async (e) => {
await trigger(\"atto::debounce\", [\"users::follow\"]);
fetch(\"/api/v1/auth/user/{{ profile.id }}/follow\", {
fetch(\"/api/v1/auth/user/{{ profile.id }}/follow/toggle\", {
method: \"POST\",
})
.then((res) => res.json())

View file

@ -35,6 +35,87 @@
(text "{{ macros::profile_settings_nav_options() }}"))
; ...
(div
("class" "w-full flex flex-col gap-2 hidden")
("data-tab" "presets")
(div
("class" "card lowered flex flex-col gap-2")
(a
("href" "#/account")
("class" "button secondary")
(icon (text "arrow-left"))
(span
(str (text "general:action.back"))))
(div
("class" "card-nest")
(div
("class" "card flex items-center gap-2 small")
(icon (text "cooking-pot"))
(span
(str (text "settings:tab.presets"))))
(div
("class" "card flex flex-col gap-2 secondary")
(p (text "Not sure where to start? Try some settings presets!"))
(details
("class" "w-full accordion")
(summary
(icon (text "rss"))
(text "Microblogging"))
(div
("class" "inner flex flex-col gap-2")
(p ("class" "fade") (text "Focus on yourself and your communities."))
(ul ("id" "preset_microblogging_ul"))
(button
("onclick" "apply_preset(PRESET_MICROBLOGGING)")
(icon (text "settings"))
(str (text "general:action.apply")))))
(details
("class" "w-full accordion")
(summary
(icon (text "message-circle-heart"))
(text "Q&A"))
(div
("class" "inner flex flex-col gap-2")
(p ("class" "fade") (text "Just like Neospring!"))
(ul ("id" "preset_questions_ul"))
(button
("onclick" "apply_preset(PRESET_QUESTIONS)")
(icon (text "settings"))
(str (text "general:action.apply")))))
(details
("class" "w-full accordion")
(summary
(icon (text "key"))
(text "Private"))
(div
("class" "inner flex flex-col gap-2")
(p ("class" "fade") (text "This preset allows you to keep your profile and posts hidden to people you aren't following."))
(ul ("id" "preset_private_ul"))
(button
("onclick" "apply_preset(PRESET_PRIVATE)")
(icon (text "settings"))
(str (text "general:action.apply")))))
(details
("class" "w-full accordion")
(summary
(icon (text "eye-closed"))
(text "NSFW"))
(div
("class" "inner flex flex-col gap-2")
(p ("class" "fade") (text "NSFW content is allowed if it is hidden from main timelines. This preset will help you do that quickly."))
(ul ("id" "preset_nsfw_ul"))
(button
("onclick" "apply_preset(PRESET_NSFW)")
(icon (text "settings"))
(str (text "general:action.apply")))))))))
(div
("class" "w-full flex flex-col gap-2")
("data-tab" "account")
@ -56,6 +137,12 @@
(text "{{ icon \"rss\" }}")
(span
(text "{{ text \"auth:label.following\" }}")))
(a
("data-tab-button" "account/followers")
("href" "#/account/followers")
(text "{{ icon \"rss\" }}")
(span
(text "{{ text \"auth:label.followers\" }}")))
(a
("data-tab-button" "account/blocks")
("href" "#/account/blocks")
@ -134,15 +221,16 @@
("selected" "{% if home == '/all/questions' -%}true{% else %}false{%- endif %}")
(text "All (questions)"))
(text "{% for stack in stacks %}")
(option
("value" "{\\\"Stack\\\":\\\"{{ stack.id }}\\\"}")
("selected" "{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}")
(text "{{ stack.name }} (stack)"))
(text "<option
value='{\"Stack\":\"{{ stack.id }}\"}'
selected=\"{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}\"
>
{{ stack.name }} (stack)
</option>")
(text "{% endfor %}"))
(span
("class" "fade")
(text "This represents the timeline the home button takes you
to."))))
(text "This represents the timeline the home button takes you to."))))
(div
("class" "card-nest desktop")
("ui_ident" "notifications")
@ -188,7 +276,6 @@
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}"))))))
@ -197,30 +284,50 @@
("ui_ident" "delete_account")
(div
("class" "card small flex items-center gap-2 red")
(text "{{ icon \"skull\" }}")
(b
(text "{{ text \"settings:label.delete_account\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "delete_account(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "current_password")
(text "{{ text \"settings:label.current_password\" }}"))
(input
("type" "password")
("name" "current_password")
("id" "current_password")
("placeholder" "current_password")
("required" "")
("minlength" "6")
("autocomplete" "off")))
(button
("class" "primary")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}")))))
(icon (text "skull"))
(b (str (text "communities:label.danger_zone"))))
(div
("class" "card lowered flex flex-col gap-2")
(details
("class" "accordion")
(summary
("class" "flex items-center gap-2")
(icon_class (text "chevron-down") (text "dropdown_arrow"))
(str (text "settings:label.deactivate_account")))
(div
("class" "inner flex flex-col gap-2")
(p (text "Deactivating your account will treat it as deleted, but all your data will be recoverable if you change your mind. This option is recommended over a full deletion."))
(button
("onclick" "deactivate_account()")
(icon (text "lock"))
(span
(str (text "settings:label.deactivate"))))))
(details
("class" "accordion")
(summary
("class" "flex items-center gap-2")
(icon_class (text "chevron-down") (text "dropdown_arrow"))
(str (text "settings:label.delete_account")))
(form
("class" "inner flex flex-col gap-2")
("onsubmit" "delete_account(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "current_password")
(text "{{ text \"settings:label.current_password\" }}"))
(input
("type" "password")
("name" "current_password")
("id" "current_password")
("placeholder" "current_password")
("required" "")
("minlength" "6")
("autocomplete" "off")))
(button
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}")))))))
(button
("onclick" "save_settings()")
("id" "save_button")
@ -331,7 +438,6 @@
("minlength" "6")
("autocomplete" "off")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))))))))
@ -375,7 +481,7 @@
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"requests:action.view_profile\" }}")))))
(text "{% endfor %}"))))
(text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/following\") }}"))))
(script
(text "globalThis.toggle_follow_user = async (uid) => {
await trigger(\"atto::debounce\", [\"users::follow\"]);
@ -391,6 +497,62 @@
]);
});
};")))
(div
("class" "w-full flex flex-col gap-2 hidden")
("data-tab" "account/followers")
(div
("class" "card lowered flex flex-col gap-2")
(a
("href" "#/account")
("class" "button secondary")
(text "{{ icon \"arrow-left\" }}")
(span
(text "{{ text \"general:action.back\" }}")))
(div
("class" "card-nest")
(div
("class" "card flex items-center gap-2 small")
(text "{{ icon \"rss\" }}")
(span
(text "{{ text \"auth:label.followers\" }}")))
(div
("class" "card flex flex-col gap-2")
(text "{% for userfollow in followers %} {% set user = userfollow[1] %}")
(div
("class" "card secondary flex flex-wrap gap-2 items-center justify-between")
(div
("class" "flex gap-2")
(text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}"))
(div
("class" "flex gap-2")
(button
("class" "lowered red small")
("onclick" "force_unfollow_me('{{ user.id }}')")
(text "{{ icon \"user-minus\" }}")
(span
(str (text "stacks:label.remove"))))
(a
("href" "/@{{ user.username }}")
("class" "button lowered small")
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"requests:action.view_profile\" }}")))))
(text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/followers\") }}"))))
(script
(text "globalThis.force_unfollow_me = async (uid) => {
await trigger(\"atto::debounce\", [\"users::follow\"]);
fetch(`/api/v1/auth/user/${uid}/force_unfollow_me`, {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};")))
(div
("class" "w-full flex flex-col gap-2 hidden")
("data-tab" "account/blocks")
@ -492,32 +654,51 @@
(div
("class" "card flex flex-col gap-2 secondary")
(text "{{ components::supporter_ad(body=\"Become a supporter to upload images directly to posts!\") }} {% for upload in uploads %}")
(div
("class" "card flex flex-wrap gap-2 items-center justify-between")
(details
("class" "accordion w-full")
(summary
("class" "card flex flex-wrap gap-2 items-center justify-between")
(div
("class" "flex gap-2 items-center")
(icon_class (text "chevron-down") (text "dropdown_arrow"))
(b
(span
("class" "date")
(text "{{ upload.created }}"))
(text " ({{ upload.what }})")))
(div
("class" "flex gap-2")
(button
("class" "raised small")
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])")
(text "{{ icon \"view\" }}")
(span
(text "{{ text \"general:action.view\" }}")))
(button
("class" "raised small red")
("onclick" "remove_upload('{{ upload.id }}')")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"stacks:label.remove\" }}")))))
(div
("class" "flex gap-2 items-center")
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])")
("style" "cursor: pointer")
(text "{{ icon \"file-image\" }}")
(b
(span
("class" "date")
(text "{{ upload.created }}"))
(text "({{ upload.what }})")))
(div
("class" "flex gap-2")
(button
("class" "lowered small")
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])")
(text "{{ icon \"view\" }}")
(span
(text "{{ text \"general:action.view\" }}")))
(button
("class" "lowered small red")
("onclick" "remove_upload('{{ upload.id }}')")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"stacks:label.remove\" }}")))))
("class" "inner flex flex-col gap-2")
(form
("class" "card lowered flex flex-col gap-2")
("onsubmit" "update_upload_alt(event, '{{ upload.id }}')")
(div
("class" "flex flex-col gap-1")
(label ("for" "alt_{{ upload.id }}") (b (str (text "settings:label.alt_text"))))
(textarea
("id" "alt_{{ upload.id }}")
("name" "alt")
("class" "w-full")
("placeholder" "Alternative text")
(text "{{ upload.alt|safe }}")))
(button
(icon (text "check"))
(str (text "general:action.save"))))))
(text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}")
(script
(text "globalThis.remove_upload = async (id) => {
@ -539,6 +720,26 @@
res.message,
]);
});
};
globalThis.update_upload_alt = async (e, id) => {
e.preventDefault();
fetch(`/api/v1/uploads/${id}/alt`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
alt: e.target.alt.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};"))))))
(text "{% if config.security.enable_invite_codes -%}")
@ -643,6 +844,29 @@
(div
("class" "card flex flex-col gap-2 secondary")
(text "{% if config.stripe -%}")
(text "{% if has_developer_pass or is_supporter -%}")
(div
("class" "card-nest")
("ui_ident" "supporter_card")
(div
("class" "card small flex items-center gap-2")
(icon (text "credit-card"))
(b
(text "Manage billing")))
(div
("class" "card flex flex-col gap-2")
(p
(text "You currently have a subscription! You can manage your billing information below. ")
(b
(text "Please use your email address you supplied when paying to log into the billing portal."))
(text " You can manage all of your active subscriptions through this page."))
(a
("href" "{{ config.stripe.billing_portal_url }}")
("class" "button lowered")
("target" "_blank")
(text "Manage billing"))))
(text "{%- endif %}")
(div
("class" "card-nest")
("ui_ident" "supporter_card")
@ -652,28 +876,33 @@
(b
(text "Supporter status")))
(div
("class" "card flex flex-col gap-2")
("class" "card flex flex-col gap-2 no_p_margin")
(text "{% if is_supporter -%}")
(p
(text "You ")
(b
(text "are "))
(text "a supporter! Thank you for all
that you do. You can manage your billing
information below.")
(b
(text "Please use your email address you supplied
when paying to login to the billing
portal.")))
(a
("href" "{{ config.stripe.billing_portal_url }}")
("class" "button lowered")
("target" "_blank")
(text "Manage billing"))
(b (text "are "))
(text "a supporter! Thank you for all that you do."))
(text "{% else %}")
(text "{{ components::become_supporter_button() }}")
(text "{%- endif %}")))
(div
("class" "card-nest")
("ui_ident" "supporter_card")
(div
("class" "card small flex items-center gap-2")
(icon (text "id-card-lanyard"))
(b
(text "Developer pass status")))
(div
("class" "card flex flex-col gap-2 no_p_margin")
(text "{% if has_developer_pass -%}")
(p
(text "You currently have a developer pass!"))
(text "{% else %}")
(text "{{ components::get_developer_pass_button() }}")
(text "{%- endif %}")))
(text "{% if user.was_purchased and user.invite_code == 0 -%}")
(form
("class" "card w-full lowered flex flex-col gap-2")
@ -725,7 +954,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w-content"))
(button
("class" "primary")
(text "{{ icon \"check\" }}")))
(span
("class" "fade")
@ -753,7 +981,6 @@
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w-content"))
(button
("class" "primary")
(text "{{ icon \"check\" }}")))
(span
("class" "fade")
@ -779,7 +1006,23 @@
(text "Responses")))
(span
("class" "fade")
(text "This represents the timeline that is shown on your profile by default.")))))
(text "This represents the timeline that is shown on your profile by default."))))
(div
("class" "flex flex-col gap-2")
("ui_ident" "show_presets")
(hr ("class" "margin"))
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "Not sure what to do?")))
(div
("class" "card no_p_margin")
(p
(text "Quickly set up your account with ")
(a ("href" "/settings#/presets") (text "settings presets"))
(text "!"))))))
(button
("onclick" "save_settings()")
("id" "save_button")
@ -855,7 +1098,6 @@
("class" "card w-full flex flex-wrap gap-2")
("ui_ident" "import_export")
(button
("class" "primary")
("onclick" "import_theme_settings()")
(text "{{ icon \"upload\" }}")
(span
@ -1391,6 +1633,87 @@
});
}
globalThis.deactivate_account = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/auth/user/{{ profile.id }}/deactivate\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({ is_deactivated: true }),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
// presets
globalThis.apply_preset = async (preset) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This will change all listed settings to their listed values.\",
]))
) {
return;
}
for (const x of preset) {
window.SETTING_SET_FUNCTIONS[0](x[0], x[1])
}
save_settings();
}
globalThis.render_preset_lis = (preset, id) => {
for (const x of preset) {
document.getElementById(id).innerHTML += `<li><b>${x[0]}:</b> ${x[1]}</li>`;
}
}
globalThis.PRESET_MICROBLOGGING = [
[\"default_timeline\", \"All\"],
[\"all_timeline_hide_answers\", true],
];
globalThis.PRESET_QUESTIONS = [
[\"default_timeline\", \"Following\"],
[\"auto_full_unlist\", true],
[\"enable_questions\", true],
[\"allow_anonymous_questions\", true],
[\"enable_drawings\", true],
[\"hide_extra_post_tabs\", true],
];
globalThis.PRESET_PRIVATE = [
[\"private_profile\", true],
[\"private_last_seen\", true],
[\"private_communities\", true],
[\"private_chats\", true],
[\"require_account\", true],
];
globalThis.PRESET_NSFW = [
[\"auto_unlist\", true],
[\"show_nsfw\", true],
];
render_preset_lis(PRESET_MICROBLOGGING, \"preset_microblogging_ul\");
render_preset_lis(PRESET_QUESTIONS, \"preset_questions_ul\");
render_preset_lis(PRESET_PRIVATE, \"preset_private_ul\");
render_preset_lis(PRESET_NSFW, \"preset_nsfw_ul\");
// ...
const account_settings =
document.getElementById(\"account_settings\");
const profile_settings =
@ -1410,6 +1733,7 @@
\"change_avatar\",
\"change_banner\",
\"default_profile_page\",
\"show_presets\",
]);
ui.refresh_container(theme_settings, [
\"supporter_ad\",
@ -1432,6 +1756,15 @@
settings.biography,
\"textarea\",
],
[
[\"private_biography\", \"Private biography\"],
settings.private_biography,
\"textarea\",
{
embed_html:
'<span class=\"fade\">This biography is only shown to users you are not following while your account is private.</span>',
},
],
[[\"status\", \"Status\"], settings.status, \"textarea\"],
[
[\"warning\", \"Profile warning\"],
@ -1525,6 +1858,16 @@
\"{{ profile.settings.auto_unlist }}\",
\"checkbox\",
],
[
[\"auto_full_unlist\", \"Only publish my posts to my profile\"],
\"{{ profile.settings.auto_full_unlist }}\",
\"checkbox\",
],
[
[],
\"Your posts will still be visible in the \\\"Following\\\" timeline for users following you.\",
\"text\",
],
[
[\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'],
\"{{ profile.settings.all_timeline_hide_answers }}\",
@ -1538,6 +1881,22 @@
\"{{ profile.settings.hide_associated_blocked_users }}\",
\"checkbox\",
],
[
[
\"hide_from_social_lists\",
\"Hide my profile from social lists (followers/following)\",
],
\"{{ profile.settings.hide_from_social_lists }}\",
\"checkbox\",
],
[
[
\"hide_social_follows\",
\"Hide followers/following links on my profile\",
],
\"{{ profile.settings.hide_social_follows }}\",
\"checkbox\",
],
[[], \"Questions\", \"title\"],
[
[

View file

@ -70,9 +70,13 @@
(str (text "general:label.account_banned")))
(div
("class" "card")
(str (text "general:label.account_banned_body"))))))
("class" "card flex flex-col gap-2 no_p_margin")
(str (text "general:label.account_banned_body"))
(hr)
(span ("class" "fade") (text "The following reason was provided by a moderator:"))
(div
("class" "card lowered w-full")
(text "{{ user.ban_reason|markdown|safe }}"))))))
; if we aren't banned, just show the page body
(text "{% elif user and user.awaiting_purchase %}")
; account waiting for payment message
@ -137,6 +141,55 @@
}
});
}"))))))
(text "{% elif user.is_deactivated -%}")
; account deactivated message
(article
(main
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2 red")
(icon (text "frown"))
(str (text "settings:label.account_deactivated")))
(div
("class" "card flex flex-col gap-2 no_p_margin")
(p (text "You have deactivated your account. You can undo this with the button below if you'd like."))
(hr)
(button
("onclick" "activate_account()")
(icon (text "lock-open"))
(str (text "settings:label.activate_account")))))))
(script
(text "globalThis.activate_account = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/auth/user/{{ user.id }}/deactivate\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({ is_deactivated: false }),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
};"))
(text "{% else %}")
; page body
(text "{% block body %}{% endblock %}")

View file

@ -29,7 +29,6 @@
("minlength" "2")
("maxlength" "32")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(text "{%- endif %}")
(div

View file

@ -114,7 +114,6 @@
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}"))))))

View file

@ -24,6 +24,18 @@
(a
("href" "/communities/search")
(text "searching for a community to join!")))))
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "Need help getting started?")))
(div
("class" "card no_p_margin")
(p
(text "Quickly set up your account with ")
(a ("href" "/settings#/presets") (text "settings presets"))
(text "!"))))
(text "{% else %}")
(div
("class" "card w-full flex flex-col gap-2")

View file

@ -6,4 +6,11 @@
xmlns="http://www.w3.org/2000/svg"
>
<rect width="460" height="460" fill="#E793B9" />
<ellipse cx="125" cy="205" rx="23" ry="24" fill="#FFBFDD" />
<circle cx="334" cy="205" r="24" fill="#FFBFDD" />
<path
d="M281.204 235.5C284.405 235.5 287.05 238.115 286.488 241.266C285.823 244.997 284.514 248.655 282.585 252.147C279.67 257.424 275.398 262.22 270.012 266.259C264.626 270.298 258.233 273.503 251.196 275.689C244.159 277.875 236.617 279 229 279C221.383 279 213.841 277.875 206.804 275.689C199.767 273.503 193.374 270.298 187.988 266.259C182.602 262.22 178.33 257.424 175.415 252.147C173.486 248.655 172.177 244.997 171.512 241.266C170.95 238.115 173.595 235.5 176.796 235.5V235.5C179.998 235.5 182.533 238.125 183.23 241.25C183.809 243.841 184.779 246.381 186.125 248.819C188.458 253.042 191.876 256.879 196.185 260.111C200.495 263.343 205.61 265.907 211.241 267.656C216.871 269.405 222.906 270.305 229 270.305C235.094 270.305 241.129 269.405 246.759 267.656C252.39 265.907 257.505 263.343 261.815 260.111C266.124 256.879 269.542 253.042 271.875 248.819C273.221 246.381 274.191 243.841 274.77 241.25C275.467 238.125 278.002 235.5 281.204 235.5V235.5Z"
fill="#FFBFDD"
fill-opacity="0.984314"
/>
</svg>

Before

Width:  |  Height:  |  Size: 181 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

@ -0,0 +1 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 468 222.5" style="enable-background:new 0 0 468 222.5" xml:space="preserve"><style>.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#635bff}</style><path class="st0" d="M414 113.4c0-25.6-12.4-45.8-36.1-45.8-23.8 0-38.2 20.2-38.2 45.6 0 30.1 17 45.3 41.4 45.3 11.9 0 20.9-2.7 27.7-6.5v-20c-6.8 3.4-14.6 5.5-24.5 5.5-9.7 0-18.3-3.4-19.4-15.2h48.9c0-1.3.2-6.5.2-8.9zm-49.4-9.5c0-11.3 6.9-16 13.2-16 6.1 0 12.6 4.7 12.6 16h-25.8zM301.1 67.6c-9.8 0-16.1 4.6-19.6 7.8l-1.3-6.2h-22v116.6l25-5.3.1-28.3c3.6 2.6 8.9 6.3 17.7 6.3 17.9 0 34.2-14.4 34.2-46.1-.1-29-16.6-44.8-34.1-44.8zm-6 68.9c-5.9 0-9.4-2.1-11.8-4.7l-.1-37.1c2.6-2.9 6.2-4.9 11.9-4.9 9.1 0 15.4 10.2 15.4 23.3 0 13.4-6.2 23.4-15.4 23.4zM223.8 61.7l25.1-5.4V36l-25.1 5.3zM223.8 69.3h25.1v87.5h-25.1zM196.9 76.7l-1.6-7.4h-21.6v87.5h25V97.5c5.9-7.7 15.9-6.3 19-5.2v-23c-3.2-1.2-14.9-3.4-20.8 7.4zM146.9 47.6l-24.4 5.2-.1 80.1c0 14.8 11.1 25.7 25.9 25.7 8.2 0 14.2-1.5 17.5-3.3V135c-3.2 1.3-19 5.9-19-8.9V90.6h19V69.3h-19l.1-21.7zM79.3 94.7c0-3.9 3.2-5.4 8.5-5.4 7.6 0 17.2 2.3 24.8 6.4V72.2c-8.3-3.3-16.5-4.6-24.8-4.6C67.5 67.6 54 78.2 54 95.9c0 27.6 38 23.2 38 35.1 0 4.6-4 6.1-9.6 6.1-8.3 0-18.9-3.4-27.3-8v23.8c9.3 4 18.7 5.7 27.3 5.7 20.8 0 35.1-10.3 35.1-28.2-.1-29.8-38.2-24.5-38.2-35.7z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,338 @@
import {
JSONParse as json_parse,
JSONStringify as json_stringify,
} from "https://unpkg.com/json-with-bigint@3.4.4/json-with-bigint.js";
/// PKCE key generation.
export const PKCE = {
/// Create a verifier for [`PKCE::challenge`].
verifier: async (length) => {
let text = "";
const possible =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < length; i++) {
text += possible.charAt(
Math.floor(Math.random() * possible.length),
);
}
return text;
},
/// Create the challenge needed to request a user token.
challenge: async (verifier) => {
const data = new TextEncoder().encode(verifier);
const digest = await window.crypto.subtle.digest("SHA-256", data);
return btoa(
String.fromCharCode.apply(null, [...new Uint8Array(digest)]),
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
},
};
export default function tetratto({
host = "https://tetratto.com",
api_key = null,
app_id = 0n,
user_token = null,
user_verifier = null,
user_id = 0n,
}) {
const GRANT_URL = `${host}/auth/connections_link/app/${app_id}`;
function api_promise(res) {
return new Promise((resolve, reject) => {
if (res.ok) {
resolve(res.payload);
} else {
reject(res.message);
}
});
}
// app data
async function app() {
if (!api_key) {
throw Error("No API key provided.");
}
return api_promise(
json_parse(
await (
await fetch(`${host}/api/v1/app_data/app`, {
method: "GET",
headers: {
"Atto-Secret-Key": api_key,
},
})
).text(),
),
);
}
async function check_ip(ip) {
if (!api_key) {
throw Error("No API key provided.");
}
return api_promise(
json_parse(
await (
await fetch(`${host}/api/v1/bans/${ip}`, {
method: "GET",
headers: {
"Atto-Secret-Key": api_key,
},
})
).text(),
),
);
}
async function query(body) {
if (!api_key) {
throw Error("No API key provided.");
}
return api_promise(
json_parse(
await (
await fetch(`${host}/api/v1/app_data/query`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Atto-Secret-Key": api_key,
},
body: json_stringify(body),
})
).text(),
),
);
}
async function insert(key, value) {
if (!api_key) {
throw Error("No API key provided.");
}
return api_promise(
json_parse(
await (
await fetch(`${host}/api/v1/app_data`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Atto-Secret-Key": api_key,
},
body: json_stringify({
key,
value,
}),
})
).text(),
),
);
}
async function update(id, value) {
if (!api_key) {
throw Error("No API key provided.");
}
return api_promise(
json_parse(
await (
await fetch(`${host}/api/v1/app_data/${id}/value`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Atto-Secret-Key": api_key,
},
body: json_stringify({
value,
}),
})
).text(),
),
);
}
async function rename(id, key) {
if (!api_key) {
throw Error("No API key provided.");
}
return api_promise(
json_parse(
await (
await fetch(`${host}/api/v1/app_data/${id}/key`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Atto-Secret-Key": api_key,
},
body: json_stringify({
key,
}),
})
).text(),
),
);
}
async function remove(id) {
if (!api_key) {
throw Error("No API key provided.");
}
return api_promise(
json_parse(
await (
await fetch(`${host}/api/v1/app_data/${id}`, {
method: "DELETE",
headers: {
"Atto-Secret-Key": api_key,
},
})
).text(),
),
);
}
async function remove_query(body) {
if (!api_key) {
throw Error("No API key provided.");
}
return api_promise(
json_parse(
await (
await fetch(`${host}/api/v1/app_data/query`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"Atto-Secret-Key": api_key,
},
body: json_stringify(body),
})
).text(),
),
);
}
// user connection
/// Extract the verifier, token, and user ID from the URL.
function extract_verifier_token_uid() {
const search = new URLSearchParams(window.location.search);
return [
search.get("verifier"),
search.get("token"),
BigInt(search.get("uid")),
];
}
/// Accept a connection grant and store it in localStorage.
function localstorage_accept_connection() {
const [verifier, token, uid] = extract_verifier_token_uid();
window.localStorage.setItem("atto:grant.verifier", verifier);
window.localStorage.setItem("atto:grant.token", token);
window.localStorage.setItem("atto:grant.user_id", uid);
}
async function refresh_token() {
return api_promise(
json_parse(
await (
await fetch(
`${host}/api/v1/auth/user/${user_id}/grants/${app_id}/refresh`,
{
method,
headers: {
"Content-Type": "application/json",
"X-Cookie": `Atto-Grant=${user_token}`,
},
body: json_stringify({
verifier: user_verifier,
}),
},
)
).text(),
),
);
}
async function request({
route,
method = "POST",
content_type = "application/json",
body = {},
}) {
if (!user_token) {
throw Error("No user token provided.");
}
return api_promise(
json_parse(
await (
await fetch(`${host}/api/v1/${route}`, {
method,
headers: {
"Content-Type":
method === "GET" ? null : content_type,
"X-Cookie": `Atto-Grant=${user_token}`,
},
body:
method === "GET"
? null
: content_type === "application/json"
? json_stringify(body)
: body,
})
).text(),
),
);
}
// ...
return {
user_id,
user_token,
user_verifier,
app_id,
api_key,
// app data
app,
check_ip,
query,
insert,
update,
rename,
remove,
remove_query,
// user connection
GRANT_URL,
extract_verifier_token_uid,
refresh_token,
localstorage_accept_connection,
request,
};
}
export function from_localstorage({
host = "https://tetratto.com",
app_id = 0n,
}) {
const user_verifier = window.localStorage.getItem("atto:grant.verifier");
const user_token = window.localStorage.getItem("atto:grant.token");
const user_id = window.localStorage.getItem("atto:grant.user_id");
return tetratto({
host,
app_id,
user_verifier,
user_id,
user_token,
});
}

View file

@ -156,9 +156,7 @@ media_theme_pref();
.replaceAll(" year ago", "y");
}
element.innerText =
pretty === undefined ? then.toLocaleDateString() : pretty;
element.innerText = !pretty ? then.toLocaleDateString() : pretty;
element.style.display = "inline-block";
}
});
@ -198,9 +196,7 @@ media_theme_pref();
.replaceAll(" year ago", "y")
.replaceAll("Yesterday", "1d") || "";
element.innerText =
pretty === undefined ? then.toLocaleDateString() : pretty;
element.innerText = !pretty ? then.toLocaleDateString() : pretty;
element.style.display = "inline-block";
}
});
@ -419,33 +415,35 @@ media_theme_pref();
});
self.define("hooks::long_text.init", (_) => {
for (const element of Array.from(
document.querySelectorAll("[hook=long]") || [],
)) {
const is_long = element.innerText.length >= 64 * 8;
setTimeout(() => {
for (const element of Array.from(
document.querySelectorAll("[hook=long]") || [],
)) {
const is_long = element.innerText.length >= 64 * 8;
if (!is_long) {
continue;
if (!is_long) {
continue;
}
element.classList.add("hook:long.hidden_text");
if (element.getAttribute("hook-arg") === "lowered") {
element.classList.add("hook:long.hidden_text+lowered");
}
const html = element.innerHTML;
const short = html.slice(0, 64 * 8);
element.innerHTML = `${short}...`;
// event
const listener = () => {
self["hooks::long"](element, html);
element.removeEventListener("click", listener);
};
element.addEventListener("click", listener);
}
element.classList.add("hook:long.hidden_text");
if (element.getAttribute("hook-arg") === "lowered") {
element.classList.add("hook:long.hidden_text+lowered");
}
const html = element.innerHTML;
const short = html.slice(0, 64 * 8);
element.innerHTML = `${short}...`;
// event
const listener = () => {
self["hooks::long"](element, html);
element.removeEventListener("click", listener);
};
element.addEventListener("click", listener);
}
}, 150);
});
self.define("hooks::alt", (_) => {
@ -691,7 +689,7 @@ media_theme_pref();
});
self.define("hooks::check_message_reactions", async ({ $ }) => {
const observer = $.offload_work_to_client_when_in_view(
const observer = await $.offload_work_to_client_when_in_view(
async (element) => {
const reactions = await (
await fetch(
@ -1069,7 +1067,13 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
// permissions ui
self.define(
"generate_permissions_ui",
(_, permissions, field_id = "role") => {
(
_,
permissions,
field_id = "role",
add_name = "add_permission_to_role",
remove_name = "remove_permission_from_role",
) => {
function all_matching_permissions(role) {
const matching = [];
const not_matching = [];
@ -1099,7 +1103,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
function get_permissions_html(role, id) {
const [matching, not_matching] = all_matching_permissions(role);
globalThis.remove_permission_from_role = (permission) => {
globalThis[remove_name] = (permission) => {
matching.splice(matching.indexOf(permission), 1);
not_matching.push(permission);
@ -1107,7 +1111,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
get_permissions_html(rebuild_role(matching), id);
};
globalThis.add_permission_to_role = (permission) => {
globalThis[add_name] = (permission) => {
not_matching.splice(not_matching.indexOf(permission), 1);
matching.push(permission);
@ -1120,14 +1124,14 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
for (const match of matching) {
permissions_html += `<div class="card w-full secondary flex justify-between gap-2">
<span>${match} <code>${permissions[match]}</code></span>
<button class="red lowered" onclick="remove_permission_from_role('${match}')">Remove</button>
<button class="red lowered" onclick="${remove_name}('${match}')">Remove</button>
</div>`;
}
for (const match of not_matching) {
permissions_html += `<div class="card w-full secondary flex justify-between gap-2">
<span>${match} <code>${permissions[match]}</code></span>
<button class="green lowered" onclick="add_permission_to_role('${match}')">Add</button>
<button class="green lowered" onclick="${add_name}('${match}')">Add</button>
</div>`;
}
@ -1139,8 +1143,15 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
);
// lightbox
self.define("lightbox_open", (_, src) => {
self.define("lightbox_open", async (_, src) => {
document.getElementById("lightbox_img").src = src;
const data = await (await fetch(`${src}/data`)).json();
document
.getElementById("lightbox_img")
.setAttribute("alt", data.payload.alt);
document.getElementById("lightbox_img").title = data.payload.alt;
document.getElementById("lightbox_img_a").href = src;
document.getElementById("lightbox").classList.remove("hidden");
});

View file

@ -1,762 +0,0 @@
/// Copy all the fields from one object to another.
function copy_fields(from, to) {
for (const field of Object.entries(from)) {
to[field[0]] = field[1];
}
return to;
}
/// Simple template components.
const COMPONENT_TEMPLATES = {
EMPTY_COMPONENT: { component: "empty", options: {}, children: [] },
FLEX_DEFAULT: {
component: "flex",
options: {
direction: "row",
gap: "2",
},
children: [],
},
FLEX_SIMPLE_ROW: {
component: "flex",
options: {
direction: "row",
gap: "2",
width: "full",
},
children: [],
},
FLEX_SIMPLE_COL: {
component: "flex",
options: {
direction: "col",
gap: "2",
width: "full",
},
children: [],
},
FLEX_MOBILE_COL: {
component: "flex",
options: {
collapse: "yes",
gap: "2",
width: "full",
},
children: [],
},
MARKDOWN_DEFAULT: {
component: "markdown",
options: {
text: "Hello, world!",
},
},
MARKDOWN_CARD: {
component: "markdown",
options: {
class: "card w-full",
text: "Hello, world!",
},
},
};
/// All available components with their label and JSON representation.
const COMPONENTS = [
[
"Markdown block",
COMPONENT_TEMPLATES.MARKDOWN_DEFAULT,
[["Card", COMPONENT_TEMPLATES.MARKDOWN_CARD]],
],
[
"Flex container",
COMPONENT_TEMPLATES.FLEX_DEFAULT,
[
["Simple rows", COMPONENT_TEMPLATES.FLEX_SIMPLE_ROW],
["Simple columns", COMPONENT_TEMPLATES.FLEX_SIMPLE_COL],
["Mobile columns", COMPONENT_TEMPLATES.FLEX_MOBILE_COL],
],
],
[
"Profile tabs",
{
component: "tabs",
},
],
[
"Profile feeds",
{
component: "feed",
},
],
[
"Profile banner",
{
component: "banner",
},
],
[
"Question box",
{
component: "ask",
},
],
[
"Name & avatar",
{
component: "name",
},
],
[
"About section",
{
component: "about",
},
],
[
"Action buttons",
{
component: "actions",
},
],
[
"CSS stylesheet",
{
component: "style",
options: {
data: "",
},
},
],
];
// preload icons
trigger("app::icon", ["shapes"]);
trigger("app::icon", ["type"]);
trigger("app::icon", ["plus"]);
trigger("app::icon", ["move-up"]);
trigger("app::icon", ["move-down"]);
trigger("app::icon", ["trash"]);
trigger("app::icon", ["arrow-left"]);
trigger("app::icon", ["x"]);
/// The location of an element as represented by array indexes.
class ElementPointer {
position = [];
constructor(element) {
if (element) {
const pos = [];
let target = element;
while (target.parentElement) {
const parent = target.parentElement;
// push index
pos.push(Array.from(parent.children).indexOf(target) || 0);
// update target
if (parent.id === "editor") {
break;
}
target = parent;
}
this.position = pos.reverse(); // indexes are added in reverse order because of how we traverse
} else {
this.position = [];
}
}
get() {
return this.position;
}
resolve(json, minus = 0) {
let out = json;
if (this.position.length === 1) {
// this is the first element (this.position === [0])
return out;
}
const pos = this.position.slice(1, this.position.length); // the first one refers to the root element
for (let i = 0; i < minus; i++) {
pos.pop();
}
for (const idx of pos) {
const child = ((out || { children: [] }).children || [])[idx];
if (!child) {
break;
}
out = child;
}
return out;
}
}
/// The layout editor controller.
class LayoutEditor {
element;
json;
tree = "";
current = { component: "empty" };
pointer = new ElementPointer();
/// Create a new [`LayoutEditor`].
constructor(element, json) {
this.element = element;
this.json = json;
if (this.json.json) {
delete this.json.json;
}
element.addEventListener("click", (e) => this.click(e, this));
element.addEventListener("mouseover", (e) => {
e.stopImmediatePropagation();
const ptr = new ElementPointer(e.target);
if (document.getElementById("position")) {
document.getElementById(
"position",
).parentElement.style.display = "flex";
document.getElementById("position").innerText = ptr
.get()
.join(".");
}
});
this.render();
}
/// Render layout.
render() {
fetch("/api/v0/auth/render_layout", {
method: "POST",
body: JSON.stringify({
layout: this.json,
}),
headers: {
"Content-Type": "application/json",
},
})
.then((r) => r.json())
.then((r) => {
this.element.innerHTML = r.block;
this.tree = r.tree;
if (this.json.component !== "empty") {
// remove all "empty" components (if the root component isn't an empty)
for (const element of document.querySelectorAll(
'[data-component-name="empty"]',
)) {
element.remove();
}
}
});
}
/// Editor clicked.
click(e, self) {
e.stopImmediatePropagation();
trigger("app::hooks::dropdown.close");
const ptr = new ElementPointer(e.target);
self.current = ptr.resolve(self.json);
self.pointer = ptr;
if (document.getElementById("current_position")) {
document.getElementById(
"current_position",
).parentElement.style.display = "flex";
document.getElementById("current_position").innerText = ptr
.get()
.join(".");
}
for (const element of document.querySelectorAll(
".layout_editor_block.active",
)) {
element.classList.remove("active");
}
e.target.classList.add("active");
self.screen("element");
}
/// Open sidebar.
open() {
document.getElementById("editor_sidebar").classList.add("open");
document.getElementById("editor").style.transform = "scale(0.8)";
}
/// Close sidebar.
close() {
document.getElementById("editor_sidebar").style.animation =
"0.2s ease-in-out forwards to_left";
setTimeout(() => {
document.getElementById("editor_sidebar").classList.remove("open");
document.getElementById("editor_sidebar").style.animation =
"0.2s ease-in-out forwards from_right";
}, 250);
document.getElementById("editor").style.transform = "scale(1)";
}
/// Render editor dialog.
screen(page = "element", data = {}) {
this.current.component = this.current.component.toLowerCase();
const sidebar = document.getElementById("editor_sidebar");
sidebar.innerHTML = "";
// render page
if (
page === "add" ||
(page === "element" && this.current.component === "empty")
) {
// add element
sidebar.appendChild(
(() => {
const heading = document.createElement("h3");
heading.innerText = data.add_title || "Add component";
return heading;
})(),
);
sidebar.appendChild(document.createElement("hr"));
const container = document.createElement("div");
container.className = "flex w-full gap-2 flex-wrap";
for (const component of data.components || COMPONENTS) {
container.appendChild(
(() => {
const button = document.createElement("button");
button.classList.add("secondary");
trigger("app::icon", [
data.icon || "shapes",
"icon",
]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = `${component[0]}${component[2] ? ` (${component[2].length + 1})` : ""}`;
return span;
})(),
);
button.addEventListener("click", () => {
if (component[2]) {
// render presets
return this.screen(page, {
back: ["add", {}],
add_title: "Select preset",
components: [
["Default", component[1]],
...component[2],
],
icon: "type",
});
}
// no presets
if (
page === "element" &&
this.current.component === "empty"
) {
// replace with component
copy_fields(component[1], this.current);
} else {
// add component to children
this.current.children.push(
structuredClone(component[1]),
);
}
this.render();
this.close();
});
return button;
})(),
);
}
sidebar.appendChild(container);
} else if (page === "element") {
// edit element
const name = document.createElement("div");
name.className = "flex flex-col gap-2";
name.appendChild(
(() => {
const heading = document.createElement("h3");
heading.innerText = `Edit ${this.current.component}`;
return heading;
})(),
);
name.appendChild(
(() => {
const pos = document.createElement("div");
pos.className = "notification w-content";
pos.innerText = this.pointer.get().join(".");
return pos;
})(),
);
sidebar.appendChild(name);
sidebar.appendChild(document.createElement("hr"));
// options
const options = document.createElement("div");
options.className = "card flex flex-col gap-2 w-full";
const add_option = (
label_text,
name,
valid = [],
input_element = "input",
) => {
const card = document.createElement("details");
card.className = "w-full";
const summary = document.createElement("summary");
summary.className = "w-full";
const label = document.createElement("label");
label.setAttribute("for", name);
label.className = "w-full";
label.innerText = label_text;
label.style.cursor = "pointer";
label.addEventListener("click", () => {
// bubble to summary click
summary.click();
});
const input_box = document.createElement("div");
input_box.style.paddingLeft = "1rem";
input_box.style.borderLeft =
"solid 2px var(--color-super-lowered)";
const input = document.createElement(input_element);
input.id = name;
input.setAttribute("name", name);
input.setAttribute("type", "text");
if (input_element === "input") {
input.setAttribute(
"value",
// biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code
(this.current.options || {})[name] || "",
);
} else {
// biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code
input.innerHTML = (this.current.options || {})[name] || "";
}
// biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code
if ((this.current.options || {})[name]) {
// open details if a value is set
card.setAttribute("open", "");
}
input.addEventListener("change", (e) => {
if (
valid.length > 0 &&
!valid.includes(e.target.value) &&
e.target.value.length > 0 // anything can be set to empty
) {
alert(`Must be one of: ${JSON.stringify(valid)}`);
return;
}
if (!this.current.options) {
this.current.options = {};
}
this.current.options[name] =
e.target.value === "no" ? "" : e.target.value;
});
summary.appendChild(label);
card.appendChild(summary);
input_box.appendChild(input);
card.appendChild(input_box);
options.appendChild(card);
};
sidebar.appendChild(options);
if (this.current.component === "flex") {
add_option("Gap", "gap", ["1", "2", "3", "4"]);
add_option("Direction", "direction", ["row", "col"]);
add_option("Do collapse", "collapse", ["yes", "no"]);
add_option("Width", "width", ["full", "content"]);
add_option("Class name", "class");
add_option("Unique ID", "id");
add_option("Style", "style", [], "textarea");
} else if (this.current.component === "markdown") {
add_option("Content", "text", [], "textarea");
add_option("Class name", "class");
} else if (this.current.component === "divider") {
add_option("Class name", "class");
} else if (this.current.component === "style") {
add_option("Style data", "data", [], "textarea");
} else {
options.remove();
}
// action buttons
const buttons = document.createElement("div");
buttons.className = "card w-full flex flex-wrap gap-2";
if (this.current.component === "flex") {
buttons.appendChild(
(() => {
const button = document.createElement("button");
trigger("app::icon", ["plus", "icon"]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Add child";
return span;
})(),
);
button.addEventListener("click", () => {
this.screen("add");
});
return button;
})(),
);
}
buttons.appendChild(
(() => {
const button = document.createElement("button");
trigger("app::icon", ["move-up", "icon"]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Move up";
return span;
})(),
);
button.addEventListener("click", () => {
const idx = this.pointer.get().pop();
const parent_ref = this.pointer.resolve(
this.json,
).children;
if (parent_ref[idx - 1] === undefined) {
alert("No space to move element.");
return;
}
const clone = JSON.parse(JSON.stringify(this.current));
const other_clone = JSON.parse(
JSON.stringify(parent_ref[idx - 1]),
);
copy_fields(clone, parent_ref[idx - 1]); // move here to here
copy_fields(other_clone, parent_ref[idx]); // move there to here
this.close();
this.render();
});
return button;
})(),
);
buttons.appendChild(
(() => {
const button = document.createElement("button");
trigger("app::icon", ["move-down", "icon"]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Move down";
return span;
})(),
);
button.addEventListener("click", () => {
const idx = this.pointer.get().pop();
const parent_ref = this.pointer.resolve(
this.json,
).children;
if (parent_ref[idx + 1] === undefined) {
alert("No space to move element.");
return;
}
const clone = JSON.parse(JSON.stringify(this.current));
const other_clone = JSON.parse(
JSON.stringify(parent_ref[idx + 1]),
);
copy_fields(clone, parent_ref[idx + 1]); // move here to here
copy_fields(other_clone, parent_ref[idx]); // move there to here
this.close();
this.render();
});
return button;
})(),
);
buttons.appendChild(
(() => {
const button = document.createElement("button");
button.classList.add("red");
trigger("app::icon", ["trash", "icon"]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Delete";
return span;
})(),
);
button.addEventListener("click", async () => {
if (
!(await trigger("app::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
if (this.json === this.current) {
// this is the root element; replace with empty
copy_fields(
COMPONENT_TEMPLATES.EMPTY_COMPONENT,
this.current,
);
} else {
// get parent
const idx = this.pointer.get().pop();
const ref = this.pointer.resolve(this.json);
// remove element
ref.children.splice(idx, 1);
}
this.render();
this.close();
});
return button;
})(),
);
sidebar.appendChild(buttons);
} else if (page === "tree") {
sidebar.innerHTML = this.tree;
}
sidebar.appendChild(document.createElement("hr"));
const buttons = document.createElement("div");
buttons.className = "flex gap-2 flex-wrap";
if (data.back) {
buttons.appendChild(
(() => {
const button = document.createElement("button");
button.className = "secondary";
trigger("app::icon", ["arrow-left", "icon"]).then(
(icon) => {
button.prepend(icon);
},
);
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Back";
return span;
})(),
);
button.addEventListener("click", () => {
this.screen(...data.back);
});
return button;
})(),
);
}
buttons.appendChild(
(() => {
const button = document.createElement("button");
button.className = "red secondary";
trigger("app::icon", ["x", "icon"]).then((icon) => {
button.prepend(icon);
});
button.appendChild(
(() => {
const span = document.createElement("span");
span.innerText = "Close";
return span;
})(),
);
button.addEventListener("click", () => {
this.render();
this.close();
});
return button;
})(),
);
sidebar.appendChild(buttons);
// ...
this.open();
}
}
define("ElementPointer", ElementPointer);
define("LayoutEditor", LayoutEditor);

View file

@ -193,9 +193,13 @@
like.classList.add("green");
like.querySelector("svg").classList.add("filled");
dislike.classList.remove("red");
if (dislike) {
dislike.classList.remove("red");
}
} else {
dislike.classList.add("red");
if (dislike) {
dislike.classList.add("red");
}
like.classList.remove("green");
like.querySelector("svg").classList.remove("filled");
@ -1201,3 +1205,60 @@
]);
});
})();
(() => {
const self = reg_ns("seller");
self.define("register", async () => {
await trigger("atto::debounce", ["seller::register"]);
if (
!(await trigger("atto::confirm", [
"Are you sure you want to do this?",
]))
) {
return;
}
const res = await (
await fetch("/api/v1/service_hooks/stripe/seller/register", {
method: "POST",
})
).json();
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
self.onboarding();
});
self.define("onboarding", async () => {
await trigger("atto::debounce", ["seller::onboarding"]);
const res = await (
await fetch("/api/v1/service_hooks/stripe/seller/onboarding", {
method: "POST",
})
).json();
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
if (res.ok) {
window.location.href = res.payload;
}
});
self.define("login", async () => {
await trigger("atto::debounce", ["seller::login"]);
const res = await (
await fetch("/api/v1/service_hooks/stripe/seller/login", {
method: "POST",
})
).json();
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
if (res.ok) {
window.location.href = res.payload;
}
});
})();

View file

@ -31,7 +31,9 @@ function fix_atto_links() {
if (TETRATTO_LINK_HANDLER_CTX === "embed") {
// relative links for embeds
const path = window.location.pathname.slice("/api/v1/net/".length);
const path = window.location.pathname
.replace("atto://", "")
.slice("/api/v1/net/".length);
function fix_element(
selector = "a",
@ -43,25 +45,28 @@ function fix_atto_links() {
continue;
}
let x = new URL(y[property]).pathname;
const p = new URL(y[property]).pathname.replace("atto://", "");
let x = p.startsWith("/api/v1/net/")
? p.replace("/api/v1/net/", "")
: p.startsWith("/")
? `${path.split("/")[0]}${p}`
: p;
if (!x.includes(".html")) {
x = `${x}/index.html`;
}
if (relative) {
y[property] =
`atto://${path.replace("atto://", "").split("/")[0]}${x}`;
y[property] = `atto://${x}`;
} else {
y[property] =
`/api/v1/net/atto://${path.replace("atto://", "").split("/")[0]}${x}`;
`/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}?s=${globalThis.SECRET_SESSION}`;
}
}
}
fix_element("a", "href", true);
fix_element("link", "href", false);
fix_element("script", "src", false);
fix_element("img", "src", false);
// send message
window.top.postMessage(
@ -108,12 +113,11 @@ function fix_atto_links() {
}
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}`;
window.location.href = `/api/v1/net/${href}?s=${globalThis.SECRET_SESSION}`;
}
});

View file

@ -0,0 +1,277 @@
use crate::{
get_app_from_key,
routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue, UpdateAppDataKey},
State,
};
use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json};
use tetratto_core::model::{
apps::{AppData, AppDataQuery, AppDataQueryResult},
ApiReturn, Error,
};
pub async fn get_app_request(
headers: HeaderMap,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let app = match get_app_from_key!(data, headers) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(app),
})
}
pub async fn query_request(
headers: HeaderMap,
Extension(data): Extension<State>,
Json(req): Json<QueryAppData>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let app = match get_app_from_key!(data, headers) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
match data
.query_app_data(AppDataQuery {
app: app.id,
query: req.query,
mode: req.mode,
})
.await
{
Ok(x) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(x),
}),
Err(e) => Json(e.into()),
}
}
pub async fn create_request(
headers: HeaderMap,
Extension(data): Extension<State>,
Json(req): Json<InsertAppData>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let app = match get_app_from_key!(data, headers) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
let owner = match data.get_user_by_id(app.owner).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
// check size
let new_size = app.data_used + req.value.len();
if new_size > AppData::user_limit(&owner, &app) {
return Json(Error::AppHitStorageLimit.into());
}
// ...
if let Err(e) = data.add_app_data_used(app.id, req.value.len() as i32).await {
return Json(e.into());
}
match data
.create_app_data(AppData::new(app.id, req.key, req.value))
.await
{
Ok(s) => Json(ApiReturn {
ok: true,
message: "Data inserted".to_string(),
payload: s.id.to_string(),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_key_request(
headers: HeaderMap,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAppDataKey>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let app = match get_app_from_key!(data, headers) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
let app_data = match data.get_app_data_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if app_data.app != app.id {
return Json(Error::NotAllowed.into());
}
match data.update_app_data_key(id, &req.key).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Data updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_value_request(
headers: HeaderMap,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAppDataValue>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let app = match get_app_from_key!(data, headers) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
let owner = match data.get_user_by_id(app.owner).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
let app_data = match data.get_app_data_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if app_data.app != app.id {
return Json(Error::NotAllowed.into());
}
// check size
let size_without = app.data_used - app_data.value.len();
let new_size = size_without + req.value.len();
if new_size > AppData::user_limit(&owner, &app) {
return Json(Error::AppHitStorageLimit.into());
}
// ...
// we only need to add the delta size (the next size - the old size)
if let Err(e) = data
.add_app_data_used(
app.id,
(req.value.len() as i32) - (app_data.value.len() as i32),
)
.await
{
return Json(e.into());
}
match data.update_app_data_value(id, &req.value).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Data updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn delete_request(
headers: HeaderMap,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let app = match get_app_from_key!(data, headers) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
let app_data = match data.get_app_data_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if app_data.app != app.id {
return Json(Error::NotAllowed.into());
}
// ...
if let Err(e) = data
.add_app_data_used(app.id, -(app_data.value.len() as i32))
.await
{
return Json(e.into());
}
match data.delete_app_data(id).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Data deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn delete_query_request(
headers: HeaderMap,
Extension(data): Extension<State>,
Json(req): Json<QueryAppData>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let app = match get_app_from_key!(data, headers) {
Some(x) => x,
None => return Json(Error::NotAllowed.into()),
};
// ...
let rows = match data
.query_app_data(AppDataQuery {
app: app.id,
query: req.query.clone(),
mode: req.mode.clone(),
})
.await
{
Ok(x) => match x {
AppDataQueryResult::One(x) => vec![x],
AppDataQueryResult::Many(x) => x,
},
Err(e) => return Json(e.into()),
};
let mut subtract_amount: usize = 0;
for row in &rows {
subtract_amount += row.value.len();
}
drop(rows);
if let Err(e) = data
.add_app_data_used(app.id, -(subtract_amount as i32))
.await
{
return Json(e.into());
}
match data
.query_delete_app_data(AppDataQuery {
app: app.id,
query: req.query,
mode: req.mode,
})
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Data deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -7,7 +7,7 @@ use crate::{
State,
};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{
apps::{AppQuota, ThirdPartyApp},
oauth::{AuthGrant, PkceChallengeMethod},
@ -15,7 +15,7 @@ use tetratto_core::model::{
ApiReturn, Error,
};
use tetratto_shared::{hash::random_id, unix_epoch_timestamp};
use super::CreateApp;
use super::{CreateApp, UpdateAppStorageCapacity};
pub async fn create_request(
jar: CookieJar,
@ -138,6 +138,35 @@ pub async fn update_quota_status_request(
}
}
pub async fn update_storage_capacity_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAppStorageCapacity>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if !user.permissions.check(FinePermission::MANAGE_APPS) {
return Json(Error::NotAllowed.into());
}
match data
.update_app_storage_capacity(id, req.storage_capacity)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "App updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_scopes_request(
jar: CookieJar,
Extension(data): Extension<State>,
@ -239,3 +268,34 @@ pub async fn grant_request(
Err(e) => Json(e.into()),
}
}
pub async fn roll_api_key_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let app = match data.get_app_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if user.id != app.owner {
return Json(Error::NotAllowed.into());
}
let new_key = tetratto_shared::hash::random_id_salted_len(32);
match data.update_app_api_key(id, &new_key).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "App updated".to_string(),
payload: Some(new_key),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -1,6 +1,6 @@
use std::collections::HashMap;
use axum::{extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::{
database::connections::last_fm::LastFmConnection,
model::{

View file

@ -5,7 +5,7 @@ pub mod stripe;
use std::collections::HashMap;
use axum::{extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use serde::Deserialize;
use tetratto_core::model::{
auth::{ConnectionService, ExternalConnectionData},

View file

@ -1,5 +1,5 @@
use axum::{response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::{
database::connections::spotify::SpotifyConnection,
model::{

View file

@ -1,14 +1,15 @@
use std::time::Duration;
use std::{str::FromStr, time::Duration};
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json};
use crate::cookie::CookieJar;
use tetratto_core::model::{
auth::{User, Notification},
auth::{Notification, User},
moderation::AuditLogEntry,
permissions::FinePermission,
permissions::{FinePermission, SecondaryPermission},
ApiReturn, Error,
};
use stripe::{EventObject, EventType};
use crate::State;
use crate::{get_user_from_token, State};
pub async fn stripe_webhook(
Extension(data): Extension<State>,
@ -17,9 +18,10 @@ pub async fn stripe_webhook(
) -> impl IntoResponse {
let data = &(data.read().await).0;
if data.0.0.stripe.is_none() {
return Json(Error::MiscError("Disabled".to_string()).into());
}
let stripe_cnf = match data.0.0.stripe {
Some(ref c) => c,
None => return Json(Error::MiscError("Disabled".to_string()).into()),
};
let sig = match headers.get("Stripe-Signature") {
Some(s) => s,
@ -56,7 +58,7 @@ pub async fn stripe_webhook(
Err(e) => return Json(e.into()),
};
tracing::info!("subscribe {} (stripe: {})", user.id, customer_id);
tracing::info!("payment {} (stripe: {})", user.id, customer_id);
if let Err(e) = data
.update_user_stripe_id(user.id, customer_id.as_str())
.await
@ -74,6 +76,48 @@ pub async fn stripe_webhook(
};
let customer_id = invoice.customer.unwrap().id();
let lines = invoice.lines.unwrap();
if lines.total_count.unwrap() > 1 {
if let Err(e) = data
.create_audit_log_entry(AuditLogEntry::new(
0,
format!("too many invoice line items: stripe {customer_id}"),
))
.await
{
return Json(e.into());
}
return Json(Error::MiscError("Too many line items".to_string()).into());
}
let item = match lines.data.get(0) {
Some(i) => i,
None => {
if let Err(e) = data
.create_audit_log_entry(AuditLogEntry::new(
0,
format!("too few invoice line items: stripe {customer_id}"),
))
.await
{
return Json(e.into());
}
return Json(Error::MiscError("Too few line items".to_string()).into());
}
};
let product_id = item
.price
.as_ref()
.unwrap()
.product
.as_ref()
.unwrap()
.id()
.to_string();
// pull user and update role
let mut retries: usize = 0;
@ -118,45 +162,91 @@ pub async fn stripe_webhook(
}
let user = user.unwrap();
tracing::info!("found subscription user in {retries} tries");
if user.permissions.check(FinePermission::SUPPORTER) {
return Json(ApiReturn {
ok: true,
message: "Already applied".to_string(),
payload: (),
});
}
if product_id == stripe_cnf.product_ids.supporter {
// supporter
tracing::info!("found subscription user in {retries} tries");
tracing::info!("invoice {} (stripe: {})", user.id, customer_id);
let new_user_permissions = user.permissions | FinePermission::SUPPORTER;
if user.permissions.check(FinePermission::SUPPORTER) {
return Json(ApiReturn {
ok: true,
message: "Already applied".to_string(),
payload: (),
});
}
if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
tracing::info!("invoice {} (stripe: {})", user.id, customer_id);
let new_user_permissions = user.permissions | FinePermission::SUPPORTER;
if data.0.0.security.enable_invite_codes && user.awaiting_purchase {
if let Err(e) = data
.update_user_awaiting_purchased_status(user.id, false, user.clone(), false)
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
}
if let Err(e) = data
.create_notification(Notification::new(
"Welcome new supporter!".to_string(),
"Thank you for your support! Your account has been updated with your new role."
.to_string(),
user.id,
))
.await
{
return Json(e.into());
if data.0.0.security.enable_invite_codes && user.awaiting_purchase {
if let Err(e) = data
.update_user_awaiting_purchased_status(user.id, false, user.clone(), false)
.await
{
return Json(e.into());
}
}
if let Err(e) = data
.create_notification(Notification::new(
"Welcome new supporter!".to_string(),
"Thank you for your support! Your account has been updated with your new role."
.to_string(),
user.id,
))
.await
{
return Json(e.into());
}
} else if product_id == stripe_cnf.product_ids.dev_pass {
// dev pass
tracing::info!("found subscription user in {retries} tries");
if user
.secondary_permissions
.check(SecondaryPermission::DEVELOPER_PASS)
{
return Json(ApiReturn {
ok: true,
message: "Already applied".to_string(),
payload: (),
});
}
tracing::info!("invoice {} (stripe: {})", user.id, customer_id);
let new_user_permissions =
user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS;
if let Err(e) = data
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
if let Err(e) = data
.create_notification(Notification::new(
"Welcome new developer!".to_string(),
"Thank you for your support! Your account has been updated with your new role."
.to_string(),
user.id,
))
.await
{
return Json(e.into());
}
} else {
tracing::error!(
"received an invalid stripe product id, please check config.stripe.product_ids"
);
return Json(Error::MiscError("Unknown product ID".to_string()).into());
}
}
EventType::CustomerSubscriptionDeleted => {
@ -167,34 +257,72 @@ pub async fn stripe_webhook(
};
let customer_id = subscription.customer.id();
let product_id = subscription
.items
.data
.get(0)
.as_ref()
.expect("cancelled nothing?")
.plan
.as_ref()
.expect("no subscription plan?")
.product
.as_ref()
.expect("plan with no product?")
.id()
.to_string();
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
Ok(ua) => ua,
Err(e) => return Json(e.into()),
};
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id);
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
// handle each subscription item
if product_id == stripe_cnf.product_ids.supporter {
// supporter
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id);
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0
{
// user doesn't come from an invite code, and is a purchased account
// this means their account must be locked if they stop paying
if let Err(e) = data
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
if data.0.0.security.enable_invite_codes
&& user.was_purchased
&& user.invite_code == 0
{
// user doesn't come from an invite code, and is a purchased account
// this means their account must be locked if they stop paying
if let Err(e) = data
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
.await
{
return Json(e.into());
}
}
} else if product_id == stripe_cnf.product_ids.dev_pass {
// dev pass
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id);
let new_user_permissions =
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
if let Err(e) = data
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
} else {
tracing::error!(
"received an invalid stripe product id, please check config.stripe.product_ids"
);
return Json(Error::MiscError("Unknown product ID".to_string()).into());
}
// send notification
if let Err(e) = data
.create_notification(Notification::new(
"Sorry to see you go... :(".to_string(),
@ -209,44 +337,119 @@ pub async fn stripe_webhook(
}
EventType::InvoicePaymentFailed => {
// payment failed
let subscription = match req.data.object {
EventObject::Subscription(c) => c,
let invoice = match req.data.object {
EventObject::Invoice(i) => i,
_ => unreachable!("cannot be this"),
};
let customer_id = subscription.customer.id();
let customer_id = invoice.customer.expect("TETRATTO_STRIPE_NO_CUSTOMER").id();
let item = match invoice.lines.as_ref().expect("no line items?").data.get(0) {
Some(i) => i,
None => {
if let Err(e) = data
.create_audit_log_entry(AuditLogEntry::new(
0,
format!("too few invoice line items: stripe {customer_id}"),
))
.await
{
return Json(e.into());
}
return Json(Error::MiscError("Too few line items".to_string()).into());
}
};
let product_id = item
.price
.as_ref()
.unwrap()
.product
.as_ref()
.unwrap()
.id()
.to_string();
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
Ok(ua) => ua,
Err(e) => return Json(e.into()),
};
tracing::info!(
"unsubscribe (pay fail) {} (stripe: {})",
user.id,
customer_id
);
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
// handle each subscription item
if product_id == stripe_cnf.product_ids.supporter {
// supporter
if !user.permissions.check(FinePermission::SUPPORTER) {
// the user isn't currently a supporter, there's no reason to send this notification
return Json(ApiReturn {
ok: true,
message: "Acceptable".to_string(),
payload: (),
});
}
if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
tracing::info!(
"unsubscribe (pay fail) {} (stripe: {})",
user.id,
customer_id
);
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0
{
// user doesn't come from an invite code, and is a purchased account
// this means their account must be locked if they stop paying
if let Err(e) = data
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
if data.0.0.security.enable_invite_codes
&& user.was_purchased
&& user.invite_code == 0
{
// user doesn't come from an invite code, and is a purchased account
// this means their account must be locked if they stop paying
if let Err(e) = data
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
.await
{
return Json(e.into());
}
}
} else if product_id == stripe_cnf.product_ids.dev_pass {
// dev pass
if !user
.secondary_permissions
.check(SecondaryPermission::DEVELOPER_PASS)
{
return Json(ApiReturn {
ok: true,
message: "Acceptable".to_string(),
payload: (),
});
}
tracing::info!(
"unsubscribe (pay fail) {} (stripe: {})",
user.id,
customer_id
);
let new_user_permissions =
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
if let Err(e) = data
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
} else {
tracing::error!(
"received an invalid stripe product id, please check config.stripe.product_ids"
);
return Json(Error::MiscError("Unknown product ID".to_string()).into());
}
// send notification
if let Err(e) = data
.create_notification(Notification::new(
"It seems your recent payment has failed :(".to_string(),
@ -268,3 +471,145 @@ pub async fn stripe_webhook(
payload: (),
})
}
pub async fn onboarding_account_link_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 Json(Error::NotAllowed.into()),
};
if user.seller_data.account_id.is_some() {
return Json(Error::NotAllowed.into());
}
let client = match data.3 {
Some(ref c) => c,
None => return Json(Error::Unknown.into()),
};
match stripe::AccountLink::create(
&client,
stripe::CreateAccountLink {
account: match user.seller_data.account_id {
Some(id) => stripe::AccountId::from_str(&id).unwrap(),
None => return Json(Error::NotAllowed.into()),
},
type_: stripe::AccountLinkType::AccountOnboarding,
collect: None,
expand: &[],
refresh_url: Some(&format!(
"{}/auth/connections_link/seller/refresh",
data.0.0.0.host
)),
return_url: Some(&format!(
"{}/auth/connections_link/seller/return",
data.0.0.0.host
)),
collection_options: None,
},
)
.await
{
Ok(x) => Json(ApiReturn {
ok: true,
message: "Acceptable".to_string(),
payload: Some(x.url),
}),
Err(e) => Json(Error::MiscError(e.to_string()).into()),
}
}
pub async fn create_seller_account_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await);
let mut user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.seller_data.account_id.is_some() {
return Json(Error::NotAllowed.into());
}
let client = match data.3 {
Some(ref c) => c,
None => return Json(Error::Unknown.into()),
};
let account = match stripe::Account::create(
&client,
stripe::CreateAccount {
type_: Some(stripe::AccountType::Express),
capabilities: Some(stripe::CreateAccountCapabilities {
card_payments: Some(stripe::CreateAccountCapabilitiesCardPayments {
requested: Some(true),
}),
transfers: Some(stripe::CreateAccountCapabilitiesTransfers {
requested: Some(true),
}),
..Default::default()
}),
..Default::default()
},
)
.await
{
Ok(a) => a,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
user.seller_data.account_id = Some(account.id.to_string());
match data
.0
.update_user_seller_data(user.id, user.seller_data)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Acceptable".to_string(),
payload: (),
}),
Err(e) => return Json(e.into()),
}
}
pub async fn login_link_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 Json(Error::NotAllowed.into()),
};
if user.seller_data.account_id.is_none() | !user.seller_data.completed_onboarding {
return Json(Error::NotAllowed.into());
}
let client = match data.3 {
Some(ref c) => c,
None => return Json(Error::Unknown.into()),
};
match stripe::LoginLink::create(
&client,
&stripe::AccountId::from_str(&user.seller_data.account_id.unwrap()).unwrap(),
&data.0.0.0.host,
)
.await
{
Ok(x) => Json(ApiReturn {
ok: true,
message: "Acceptable".to_string(),
payload: Some(x.url),
}),
Err(e) => Json(Error::MiscError(e.to_string()).into()),
}
}

View file

@ -4,7 +4,7 @@ use axum::{
extract::{Path, Query},
response::IntoResponse,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use pathbufd::{PathBufD, pathd};
use serde::Deserialize;
use std::{

View file

@ -1,12 +1,34 @@
use crate::{
State, get_user_from_token,
get_app_from_key, get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::CreateIpBan,
State,
};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json};
use crate::cookie::CookieJar;
use tetratto_core::model::{addr::RemoteAddr, auth::IpBan, permissions::FinePermission};
/// Check if the given IP is banned.
pub async fn check_request(
headers: HeaderMap,
Path(ip): Path<String>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
if get_app_from_key!(data, headers).is_none() {
return Json(Error::NotAllowed.into());
}
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: data
.get_ipban_by_addr(&RemoteAddr::from(ip.as_str()))
.await
.is_ok(),
})
}
/// Create a new IP ban.
pub async fn create_request(
jar: CookieJar,

View file

@ -16,7 +16,7 @@ use axum::{
response::{IntoResponse, Redirect},
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use serde::Deserialize;
use tetratto_core::model::addr::RemoteAddr;
use tetratto_shared::hash::hash;

View file

@ -1,11 +1,12 @@
use std::time::Duration;
use std::{str::FromStr, time::Duration};
use crate::{
get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::{
AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserInviteCode,
UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason,
UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword,
UpdateUserRole, UpdateUserUsername,
},
State,
};
@ -17,7 +18,7 @@ use axum::{
response::{IntoResponse, Redirect},
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use futures_util::{sink::SinkExt, stream::StreamExt};
use tetratto_core::{
cache::Cache,
@ -371,6 +372,34 @@ pub async fn update_user_awaiting_purchase_request(
}
}
/// Update the deactivated status of the given user.
///
/// Does not support third-party grants.
pub async fn update_user_is_deactivated_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<UpdateUserIsDeactivated>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.update_user_is_deactivated(id, req.is_deactivated, user)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Deactivated status updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Update the role of the given user.
///
/// Does not support third-party grants.
@ -424,6 +453,35 @@ pub async fn update_user_secondary_role_request(
}
}
/// Update the ban reason of the given user.
///
/// Does not support third-party grants.
pub async fn update_user_ban_reason_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<UpdateUserBanReason>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
match data.update_user_ban_reason(id, &req.reason).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "User updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Update the current user's last seen value.
pub async fn seen_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = &(data.read().await).0;
@ -451,8 +509,8 @@ pub async fn delete_user_request(
Extension(data): Extension<State>,
Json(req): Json<DeleteUser>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
let data = &(data.read().await);
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
@ -461,6 +519,7 @@ pub async fn delete_user_request(
return Json(Error::NotAllowed.into());
} else if user.permissions.check(FinePermission::MANAGE_USERS) {
if let Err(e) = data
.0
.create_audit_log_entry(AuditLogEntry::new(
user.id,
format!("invoked `delete_user` with x value `{id}`"),
@ -472,14 +531,32 @@ pub async fn delete_user_request(
}
match data
.0
.delete_user(id, &req.password, user.permissions.check_manager())
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "User deleted".to_string(),
payload: (),
}),
Ok(ua) => {
// delete stripe user
if let Some(stripe_id) = ua.seller_data.account_id
&& let Some(ref client) = data.3
{
if let Err(e) = stripe::Account::delete(
&client,
&stripe::AccountId::from_str(&stripe_id).unwrap(),
)
.await
{
return Json(Error::MiscError(e.to_string()).into());
}
}
// ...
Json(ApiReturn {
ok: true,
message: "User deleted".to_string(),
payload: (),
})
}
Err(e) => Json(e.into()),
}
}

View file

@ -9,7 +9,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{
addr::RemoteAddr,
auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow},
@ -17,7 +17,7 @@ use tetratto_core::model::{
};
/// Toggle following on the given user.
pub async fn follow_request(
pub async fn toggle_follow_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
@ -154,6 +154,96 @@ pub async fn accept_follow_request(
}
}
pub async fn follow_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if data
.get_userfollow_by_initiator_receiver(user.id, id)
.await
.is_ok()
{
return Json(Error::MiscError("Already following user".to_string()).into());
} else {
match data
.create_userfollow(UserFollow::new(user.id, id), &user, false)
.await
{
Ok(r) => {
if r == FollowResult::Followed {
if let Err(e) = data
.create_notification(Notification::new(
"Somebody has followed you!".to_string(),
format!(
"You have been followed by [@{}](/api/v1/auth/user/find/{}).",
user.username, user.id
),
id,
))
.await
{
return Json(e.into());
};
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::FollowUser.into(), true)
.await
{
return Json(e.into());
}
// ...
Json(ApiReturn {
ok: true,
message: "User followed".to_string(),
payload: (),
})
} else {
Json(ApiReturn {
ok: true,
message: "Asked to follow user".to_string(),
payload: (),
})
}
}
Err(e) => Json(e.into()),
}
}
}
pub async fn force_unfollow_me_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if let Ok(userfollow) = data.get_userfollow_by_receiver_initiator(user.id, id).await {
match data.delete_userfollow(userfollow.id, &user, false).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "User is no longer following you".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
} else {
return Json(Error::GeneralNotFound("user follow".to_string()).into());
}
}
/// Toggle blocking on the given user.
pub async fn block_request(
jar: CookieJar,
@ -278,7 +368,10 @@ pub async fn followers_request(
Ok(f) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: match data.fill_userfollows_with_initiator(f).await {
payload: match data
.fill_userfollows_with_initiator(f, &Some(user.clone()), id == user.id)
.await
{
Ok(f) => Some(data.userfollows_user_filter(&f)),
Err(e) => return Json(e.into()),
},
@ -310,7 +403,10 @@ pub async fn following_request(
Ok(f) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: match data.fill_userfollows_with_receiver(f).await {
payload: match data
.fill_userfollows_with_receiver(f, &Some(user.clone()), id == user.id)
.await
{
Ok(f) => Some(data.userfollows_user_filter(&f)),
Err(e) => return Json(e.into()),
},

View file

@ -9,7 +9,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{auth::UserWarning, oauth, permissions::FinePermission};
/// Create a new user warning.

View file

@ -1,5 +1,5 @@
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{oauth, channels::Channel, ApiReturn, Error};
use crate::{
get_user_from_token,
@ -293,3 +293,62 @@ pub async fn get_request(
Err(e) => Json(e.into()),
}
}
pub async fn mute_channel_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.channel_mutes.contains(&id) {
return Json(Error::MiscError("Channel already muted".to_string()).into());
}
user.channel_mutes.push(id);
match data
.update_user_channel_mutes(user.id, user.channel_mutes)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Channel muted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn unmute_channel_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let pos = match user.channel_mutes.iter().position(|x| *x == id) {
Some(x) => x,
None => return Json(Error::MiscError("Channel not muted".to_string()).into()),
};
user.channel_mutes.remove(pos);
match data
.update_user_channel_mutes(user.id, user.channel_mutes)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Channel muted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -1,6 +1,6 @@
use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error};
pub async fn get_request(

View file

@ -7,7 +7,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::{
cache::{Cache, redis::Commands},
model::{

View file

@ -3,7 +3,7 @@ use axum::{
extract::Path,
response::{IntoResponse, Redirect},
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{
auth::Notification,
communities::{Community, CommunityMembership},

View file

@ -3,7 +3,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{auth::AchievementName, communities::PostDraft, oauth, ApiReturn, Error};
use crate::{
get_user_from_token,

View file

@ -7,7 +7,7 @@ use crate::{
State,
};
use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{
oauth,
uploads::{CustomEmoji, MediaType, MediaUpload},
@ -17,6 +17,8 @@ use tetratto_core::model::{
/// Expand a unicode emoji into its Gemoji shortcode.
pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse {
match emoji.as_str() {
// matches `CustomEmoji::replace`
"💯" => "100".to_string(),
"👍" => "thumbs_up".to_string(),
"👎" => "thumbs_down".to_string(),
_ => match emojis::get(&emoji) {

View file

@ -1,5 +1,5 @@
use axum::{Extension, Json, body::Body, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use pathbufd::{PathBufD, pathd};
use std::fs::exists;
use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission, oauth};

View file

@ -4,7 +4,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{
addr::RemoteAddr,
auth::AchievementName,
@ -152,10 +152,11 @@ pub async fn create_request(
}
// ...
match data.create_post(props.clone()).await {
let uploads = props.uploads.clone();
match data.create_post(props).await {
Ok(id) => {
// write to uploads
for (i, upload_id) in props.uploads.iter().enumerate() {
for (i, upload_id) in uploads.iter().enumerate() {
let image = match images.get(i) {
Some(img) => img,
None => {
@ -723,7 +724,7 @@ pub async fn from_communities_request(
};
match data
.get_posts_from_user_communities(user.id, 12, props.page)
.get_posts_from_user_communities(user.id, 12, props.page, &user)
.await
{
Ok(posts) => {

View file

@ -4,7 +4,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{
addr::RemoteAddr,
auth::{AchievementName, IpBlock},
@ -96,6 +96,13 @@ pub async fn create_request(
props.context.mask_owner = true;
}
if !req.asking_about.is_empty() && !req.is_global {
props.context.asking_about = match req.asking_about.parse::<usize>() {
Ok(x) => Some(x),
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
}
}
match data
.create_question(props, drawings.iter().map(|x| x.to_vec()).collect())
.await

View file

@ -3,12 +3,21 @@ use crate::{
routes::api::v1::{CreateDomain, UpdateDomainData},
State,
};
use axum::{extract::Path, response::IntoResponse, http::StatusCode, Extension, Json};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{
littleweb::{Domain, ServiceFsMime},
oauth, ApiReturn, Error,
use axum::{
extract::{Path, Query},
http::StatusCode,
response::IntoResponse,
Extension, Json,
};
use crate::cookie::CookieJar;
use tetratto_core::model::{
auth::AchievementName,
littleweb::{Domain, ServiceFsMime},
oauth,
permissions::FinePermission,
ApiReturn, Error,
};
use serde::Deserialize;
pub async fn get_request(
Path(id): Path<usize>,
@ -48,11 +57,20 @@ pub async fn create_request(
Json(req): Json<CreateDomain>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) {
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateDomain.into(), true)
.await
{
return Json(e.into());
}
// ...
match data
.create_domain(Domain::new(req.name, req.tld, user.id))
.await
@ -109,13 +127,38 @@ pub async fn delete_request(
}
}
#[derive(Deserialize)]
pub struct GetFileQuery {
#[serde(default, alias = "s")]
pub session: String,
}
pub async fn get_file_request(
Path(addr): Path<String>,
Path(mut addr): Path<String>,
Extension(data): Extension<State>,
Query(props): Query<GetFileQuery>,
) -> impl IntoResponse {
if !addr.starts_with("atto://") {
addr = format!("atto://{addr}");
}
// ...
let data = &(data.read().await).0;
let user = get_user_from_token!(--browser_session = props.session, data);
let (subdomain, domain, tld, path) = Domain::from_str(&addr);
if path.starts_with("$") && user.is_none() {
return Err((StatusCode::BAD_REQUEST, Error::NotAllowed.to_string()));
} else if let Some(ref ua) = user
&& path.starts_with("$paid")
&& !ua.permissions.check(FinePermission::SUPPORTER)
{
return Err((
StatusCode::BAD_REQUEST,
Error::RequiresSupporter.to_string(),
));
}
// resolve domain
let domain = match data.get_domain_by_name_tld(&domain, &tld).await {
Ok(x) => x,
@ -145,16 +188,28 @@ pub async fn get_file_request(
Some((f, _)) => Ok((
[("Content-Type".to_string(), f.mime.to_string())],
if f.mime == ServiceFsMime::Html {
f.content.replace(
"</body>",
&format!(
"<script src=\"{}/js/proto_links.js\" defer></script></body>",
data.0.0.host
),
)
f.content
.replace(
"</body>",
&format!(
"<script src=\"{}/js/proto_links.js\" defer></script><script>
globalThis.SECRET_SESSION = \"{}\";
</script></body>",
data.0.0.host, props.session
),
)
.replace(
".js\"",
&format!(".js?r={}&s={}\"", service.revision, props.session),
)
.replace(
".css\"",
&format!(".css?r={}&s={}\"", service.revision, props.session),
)
} else {
f.content
},
}
.replace("atto://", "/api/v1/net/"),
)),
None => {
return Err((

View file

@ -3,7 +3,7 @@ use axum::{
extract::{Json, Path},
Extension,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_shared::snow::Snowflake;
use crate::{
get_user_from_token,

View file

@ -1,175 +0,0 @@
use crate::{
get_user_from_token,
routes::{
api::v1::{CreateLayout, UpdateLayoutName, UpdateLayoutPages, UpdateLayoutPrivacy},
},
State,
};
use axum::{extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar;
use tetratto_core::{
model::{
layouts::{Layout, LayoutPrivacy},
oauth,
permissions::FinePermission,
ApiReturn, Error,
},
};
pub async fn get_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let layout = match data.get_layout_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if layout.privacy == LayoutPrivacy::Public
&& user.id != layout.owner
&& !user.permissions.check(FinePermission::MANAGE_USERS)
{
return Json(Error::NotAllowed.into());
}
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(layout),
})
}
pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.get_layouts_by_user(user.id).await {
Ok(x) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(x),
}),
Err(e) => Json(e.into()),
}
}
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(req): Json<CreateLayout>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.create_layout(Layout::new(req.name, user.id, req.replaces))
.await
{
Ok(s) => Json(ApiReturn {
ok: true,
message: "Layout created".to_string(),
payload: s.id.to_string(),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_name_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateLayoutName>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_layout_title(id, &user, &req.name).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Layout updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_privacy_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateLayoutPrivacy>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_layout_privacy(id, &user, req.privacy).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Layout updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_pages_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateLayoutPages>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_layout_pages(id, &user, req.pages).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Layout updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn delete_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_layout(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Layout deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -1,12 +1,13 @@
pub mod app_data;
pub mod apps;
pub mod auth;
pub mod channels;
pub mod communities;
pub mod domains;
pub mod journals;
pub mod layouts;
pub mod notes;
pub mod notifications;
pub mod products;
pub mod reactions;
pub mod reports;
pub mod requests;
@ -21,7 +22,7 @@ use axum::{
};
use serde::Deserialize;
use tetratto_core::model::{
apps::AppQuota,
apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota},
auth::AchievementName,
communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
@ -29,10 +30,10 @@ use tetratto_core::model::{
},
communities_permissions::CommunityPermission,
journals::JournalPrivacyPermission,
layouts::{CustomizablePage, LayoutPage, LayoutPrivacy},
littleweb::{DomainData, DomainTld, ServiceFsEntry},
oauth::AppScope,
permissions::{FinePermission, SecondaryPermission},
products::{ProductPrice, ProductType},
reactions::AssetType,
stacks::{StackMode, StackPrivacy, StackSort},
};
@ -285,6 +286,10 @@ pub fn routes() -> Router {
.route("/auth/user/{id}/avatar", get(auth::images::avatar_request))
.route("/auth/user/{id}/banner", get(auth::images::banner_request))
.route("/auth/user/{id}/follow", post(auth::social::follow_request))
.route(
"/auth/user/{id}/follow/toggle",
post(auth::social::toggle_follow_request),
)
.route(
"/auth/user/{id}/follow/cancel",
post(auth::social::cancel_follow_request),
@ -293,6 +298,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/follow/accept",
post(auth::social::accept_follow_request),
)
.route(
"/auth/user/{id}/force_unfollow_me",
post(auth::social::force_unfollow_me_request),
)
.route("/auth/user/{id}/block", post(auth::social::block_request))
.route(
"/auth/user/{id}/block_ip",
@ -314,6 +323,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/role/2",
post(auth::profile::update_user_secondary_role_request),
)
.route(
"/auth/user/{id}/ban_reason",
post(auth::profile::update_user_ban_reason_request),
)
.route(
"/auth/user/{id}",
delete(auth::profile::delete_user_request),
@ -338,6 +351,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/awaiting_purchase",
post(auth::profile::update_user_awaiting_purchase_request),
)
.route(
"/auth/user/{id}/deactivate",
post(auth::profile::update_user_is_deactivated_request),
)
.route(
"/auth/user/{id}/totp",
post(auth::profile::enable_totp_request),
@ -407,6 +424,7 @@ pub fn routes() -> Router {
)
// apps
.route("/apps", post(apps::create_request))
.route("/apps/{id}", delete(apps::delete_request))
.route("/apps/{id}/title", post(apps::update_title_request))
.route("/apps/{id}/homepage", post(apps::update_homepage_request))
.route("/apps/{id}/redirect", post(apps::update_redirect_request))
@ -414,9 +432,21 @@ pub fn routes() -> Router {
"/apps/{id}/quota_status",
post(apps::update_quota_status_request),
)
.route(
"/apps/{id}/storage_capacity",
post(apps::update_storage_capacity_request),
)
.route("/apps/{id}/scopes", post(apps::update_scopes_request))
.route("/apps/{id}", delete(apps::delete_request))
.route("/apps/{id}/grant", post(apps::grant_request))
.route("/apps/{id}/roll", post(apps::roll_api_key_request))
// app data
.route("/app_data", post(app_data::create_request))
.route("/app_data/app", get(app_data::get_app_request))
.route("/app_data/{id}", delete(app_data::delete_request))
.route("/app_data/{id}/key", post(app_data::update_key_request))
.route("/app_data/{id}/value", post(app_data::update_value_request))
.route("/app_data/query", post(app_data::query_request))
.route("/app_data/query", delete(app_data::delete_query_request))
// warnings
.route("/warnings/{id}", get(auth::user_warnings::get_request))
.route("/warnings/{id}", post(auth::user_warnings::create_request))
@ -465,6 +495,7 @@ pub fn routes() -> Router {
post(communities::communities::update_membership_role),
)
// ipbans
.route("/bans/{ip}", get(auth::ipbans::check_request))
.route("/bans/{ip}", post(auth::ipbans::create_request))
.route("/bans/{ip}", delete(auth::ipbans::delete_request))
// reports
@ -514,6 +545,18 @@ pub fn routes() -> Router {
"/service_hooks/stripe",
post(auth::connections::stripe::stripe_webhook),
)
.route(
"/service_hooks/stripe/seller/register",
post(auth::connections::stripe::create_seller_account_request),
)
.route(
"/service_hooks/stripe/seller/onboarding",
post(auth::connections::stripe::onboarding_account_link_request),
)
.route(
"/service_hooks/stripe/seller/login",
post(auth::connections::stripe::login_link_request),
)
// channels
.route("/channels", post(channels::channels::create_request))
.route(
@ -537,6 +580,14 @@ pub fn routes() -> Router {
"/channels/{id}/kick",
post(channels::channels::kick_member_request),
)
.route(
"/channels/{id}/mute",
post(channels::channels::mute_channel_request),
)
.route(
"/channels/{id}/mute",
delete(channels::channels::unmute_channel_request),
)
.route("/channels/{id}", get(channels::channels::get_request))
.route(
"/channels/community/{id}",
@ -625,17 +676,8 @@ pub fn routes() -> Router {
// uploads
.route("/uploads/{id}", get(uploads::get_request))
.route("/uploads/{id}", delete(uploads::delete_request))
// layouts
.route("/layouts", get(layouts::list_request))
.route("/layouts", post(layouts::create_request))
.route("/layouts/{id}", get(layouts::get_request))
.route("/layouts/{id}", delete(layouts::delete_request))
.route("/layouts/{id}/title", post(layouts::update_name_request))
.route(
"/layouts/{id}/privacy",
post(layouts::update_privacy_request),
)
.route("/layouts/{id}/pages", post(layouts::update_pages_request))
.route("/uploads/{id}/data", get(uploads::get_json_request))
.route("/uploads/{id}/alt", post(uploads::update_alt_request))
// services
.route("/services", get(services::list_request))
.route("/services", post(services::create_request))
@ -653,6 +695,17 @@ pub fn routes() -> Router {
.route("/domains/{id}", get(domains::get_request))
.route("/domains/{id}", delete(domains::delete_request))
.route("/domains/{id}/data", post(domains::update_data_request))
// products
.route("/products", get(products::list_request))
.route("/products", post(products::create_request))
.route("/products/{id}", get(products::get_request))
.route("/products/{id}", delete(products::delete_request))
.route("/products/{id}/name", post(products::update_name_request))
.route(
"/products/{id}/description",
post(products::update_description_request),
)
.route("/products/{id}/price", post(products::update_price_request))
}
pub fn lw_routes() -> Router {
@ -793,6 +846,11 @@ pub struct UpdateUserAwaitingPurchase {
pub awaiting_purchase: bool,
}
#[derive(Deserialize)]
pub struct UpdateUserIsDeactivated {
pub is_deactivated: bool,
}
#[derive(Deserialize)]
pub struct UpdateNotificationRead {
pub read: bool,
@ -818,6 +876,11 @@ pub struct UpdateSecondaryUserRole {
pub role: SecondaryPermission,
}
#[derive(Deserialize)]
pub struct UpdateUserBanReason {
pub reason: String,
}
#[derive(Deserialize)]
pub struct UpdateUserInviteCode {
pub invite_code: String,
@ -853,6 +916,8 @@ pub struct CreateQuestion {
pub community: String,
#[serde(default)]
pub mask_owner: bool,
#[serde(default)]
pub asking_about: String,
}
#[derive(Deserialize)]
@ -947,6 +1012,7 @@ pub struct UpdatePostIsOpen {
pub struct CreateApp {
pub title: String,
pub homepage: String,
#[serde(default)]
pub redirect: String,
}
@ -970,6 +1036,11 @@ pub struct UpdateAppQuotaStatus {
pub quota_status: AppQuota,
}
#[derive(Deserialize)]
pub struct UpdateAppStorageCapacity {
pub storage_capacity: DeveloperPassStorageQuota,
}
#[derive(Deserialize)]
pub struct UpdateAppScopes {
pub scopes: Vec<AppScope>,
@ -1055,27 +1126,6 @@ pub struct AwardAchievement {
pub name: AchievementName,
}
#[derive(Deserialize)]
pub struct CreateLayout {
pub name: String,
pub replaces: CustomizablePage,
}
#[derive(Deserialize)]
pub struct UpdateLayoutName {
pub name: String,
}
#[derive(Deserialize)]
pub struct UpdateLayoutPrivacy {
pub privacy: LayoutPrivacy,
}
#[derive(Deserialize)]
pub struct UpdateLayoutPages {
pub pages: Vec<LayoutPage>,
}
#[derive(Deserialize)]
pub struct CreateService {
pub name: String,
@ -1108,3 +1158,53 @@ pub struct CreateDomain {
pub struct UpdateDomainData {
pub data: Vec<(String, DomainData)>,
}
#[derive(Deserialize)]
pub struct CreateProduct {
pub name: String,
pub description: String,
pub product_type: ProductType,
pub price: ProductPrice,
}
#[derive(Deserialize)]
pub struct UpdateProductName {
pub name: String,
}
#[derive(Deserialize)]
pub struct UpdateProductDescription {
pub description: String,
}
#[derive(Deserialize)]
pub struct UpdateProductPrice {
pub price: ProductPrice,
}
#[derive(Deserialize)]
pub struct UpdateUploadAlt {
pub alt: String,
}
#[derive(Deserialize)]
pub struct UpdateAppDataKey {
pub key: String,
}
#[derive(Deserialize)]
pub struct UpdateAppDataValue {
pub value: String,
}
#[derive(Deserialize)]
pub struct InsertAppData {
pub key: String,
pub value: String,
}
#[derive(Deserialize)]
pub struct QueryAppData {
pub query: AppDataSelectQuery,
pub mode: AppDataSelectMode,
}

View file

@ -3,7 +3,7 @@ use axum::{
extract::{Json, Path},
Extension,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_shared::unix_epoch_timestamp;
use crate::{
get_user_from_token,
@ -267,7 +267,7 @@ pub async fn delete_by_dir_request(
}
pub async fn render_markdown_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content))
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content), true)
.replace("\\@", "@")
.replace("%5C@", "@")
}

View file

@ -5,7 +5,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{oauth, ApiReturn, Error};
pub async fn delete_request(

View file

@ -0,0 +1,234 @@
use crate::{
get_user_from_token,
image::{save_webp_buffer, JsonMultipart},
routes::{
api::v1::{
communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription,
UpdateProductName, UpdateProductPrice,
},
pages::PaginatedQuery,
},
State,
};
use axum::{
extract::{Path, Query},
response::IntoResponse,
Extension, Json,
};
use crate::cookie::CookieJar;
use tetratto_core::model::{
oauth,
products::Product,
uploads::{MediaType, MediaUpload},
ApiReturn, Error,
};
pub async fn get_request(
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
match data.get_product_by_id(id).await {
Ok(x) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(x),
}),
Err(e) => return Json(e.into()),
}
}
pub async fn list_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(props): Query<PaginatedQuery>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.get_products_by_user(user.id, 12, props.page).await {
Ok(x) => Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(x),
}),
Err(e) => Json(e.into()),
}
}
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
JsonMultipart(uploads, req): JsonMultipart<CreateProduct>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if uploads.len() > 4 {
return Json(
Error::MiscError("Too many uploads. Please use a maximum of 4".to_string()).into(),
);
}
let mut product = Product::new(
user.id,
req.name,
req.description,
req.price,
req.product_type,
);
// check sizes
for img in &uploads {
if img.len() > MAXIMUM_FILE_SIZE {
return Json(Error::FileTooLarge.into());
}
}
// create uploads
for _ in 0..uploads.len() {
product.uploads.push(
match data
.create_upload(MediaUpload::new(MediaType::Webp, product.owner))
.await
{
Ok(u) => u.id,
Err(e) => return Json(e.into()),
},
);
}
let product_uploads = product.uploads.clone();
match data.create_product(product).await {
Ok(x) => {
// store uploads
for (i, upload_id) in product_uploads.iter().enumerate() {
let image = match uploads.get(i) {
Some(img) => img,
None => {
if let Err(e) = data.delete_upload(*upload_id).await {
return Json(e.into());
}
continue;
}
};
let upload = match data.get_upload_by_id(*upload_id).await {
Ok(u) => u,
Err(e) => return Json(e.into()),
};
if let Err(e) =
save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None)
{
return Json(Error::MiscError(e.to_string()).into());
}
}
// ...
Json(ApiReturn {
ok: true,
message: "Product created".to_string(),
payload: x.id.to_string(),
})
}
Err(e) => Json(e.into()),
}
}
pub async fn update_name_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateProductName>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_product_name(id, &user, &req.name).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_description_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateProductDescription>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.update_product_description(id, &user, &req.description)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_price_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateProductPrice>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_product_price(id, &user, req.price).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn delete_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_product(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -5,7 +5,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{addr::RemoteAddr, oauth, reactions::Reaction, ApiReturn, Error};
pub async fn get_request(

View file

@ -1,7 +1,7 @@
use super::CreateReport;
use crate::{State, get_user_from_token};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{ApiReturn, Error, moderation::Report};
pub async fn create_request(

View file

@ -4,7 +4,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{oauth, ApiReturn, Error};
pub async fn delete_request(

View file

@ -6,8 +6,9 @@ use crate::{
State,
};
use axum::{extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{littleweb::Service, oauth, ApiReturn, Error};
use crate::cookie::CookieJar;
use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error};
use tetratto_shared::unix_epoch_timestamp;
pub async fn get_request(
Path(id): Path<usize>,
@ -47,11 +48,20 @@ pub async fn create_request(
Json(req): Json<CreateService>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) {
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
// award achievement
if let Err(e) = data
.add_achievement(&mut user, AchievementName::CreateSite.into(), true)
.await
{
return Json(e.into());
}
// ...
match data.create_service(Service::new(req.name, user.id)).await {
Ok(x) => Json(ApiReturn {
ok: true,
@ -147,11 +157,17 @@ pub async fn update_content_request(
// ...
match data.update_service_files(id, &user, service.files).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Service updated".to_string(),
payload: (),
}),
Ok(_) => match data
.update_service_revision(id, unix_epoch_timestamp() as i64)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Service updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
},
Err(e) => Json(e.into()),
}
}

View file

@ -4,7 +4,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::{
model::{
oauth,

View file

@ -1,8 +1,8 @@
use std::fs::exists;
use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use pathbufd::PathBufD;
use crate::{get_user_from_token, State};
use crate::{get_user_from_token, routes::api::v1::UpdateUploadAlt, State};
use super::auth::images::read_image;
use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error};
@ -52,6 +52,24 @@ pub async fn get_request(
Ok(([("Content-Type", upload.what.mime())], Body::from(bytes)))
}
pub async fn get_json_request(
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let upload = match data.get_upload_by_id(id).await {
Ok(u) => u,
Err(e) => return Json(e.into()),
};
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: Some(upload),
})
}
pub async fn delete_request(
jar: CookieJar,
Extension(data): Extension<State>,
@ -72,3 +90,25 @@ pub async fn delete_request(
Err(e) => Json(e.into()),
}
}
pub async fn update_alt_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(props): Json<UpdateUploadAlt>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageUploads) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_upload_alt(id, &user, &props.alt).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Upload updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -7,7 +7,7 @@ use axum::{
response::IntoResponse,
Extension,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use pathbufd::PathBufD;
use serde::Deserialize;
use tetratto_core::model::permissions::FinePermission;

View file

@ -19,5 +19,5 @@ serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
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"));
serve_asset!(app_sdk_request: APP_SDK_JS("text/javascript"));

View file

@ -20,11 +20,8 @@ pub fn routes(config: &Config) -> Router {
.route("/js/me.js", get(assets::me_js_request))
.route("/js/streams.js", get(assets::streams_js_request))
.route("/js/carp.js", get(assets::carp_js_request))
.route(
"/js/layout_editor.js",
get(assets::layout_editor_js_request),
)
.route("/js/proto_links.js", get(assets::proto_links_request))
.route("/js/app_sdk.js", get(assets::app_sdk_request))
.nest_service(
"/public",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),

View file

@ -4,7 +4,7 @@ use axum::{
response::{Html, IntoResponse},
Extension,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{Error, auth::ConnectionService};
use super::render_error;

View file

@ -5,7 +5,7 @@ use axum::{
response::{Html, IntoResponse, Redirect},
Extension, Json,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{
channels::Message, communities_permissions::CommunityPermission, permissions::FinePermission,
Error,

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
use super::{render_error, PaginatedQuery, RepostsQuery, SearchedQuery};
use crate::{
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State,
@ -8,7 +10,7 @@ use axum::{
response::{Html, IntoResponse},
Extension,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use serde::Deserialize;
use tera::Context;
use tetratto_core::model::{
@ -122,12 +124,20 @@ macro_rules! community_context_bools {
)
} else {
false
} || if let Some(ref ua) = $user {
ua.permissions.check(tetratto_core::model::permissions::FinePermission::MANAGE_POSTS)
} else {
false
};
let can_manage_community = if let Some(ref membership) = membership {
membership.role.check(tetratto_core::model::communities_permissions::CommunityPermission::MANAGE_COMMUNITY)
} else {
false
} || if let Some(ref ua) = $user {
ua.permissions.check(tetratto_core::model::permissions::FinePermission::MANAGE_COMMUNITIES)
} else {
false
};
let can_manage_roles = if let Some(ref membership) = membership {
@ -798,7 +808,11 @@ pub async fn post_request(
let (_, reposting) = data.0.get_post_reposting(&post, &ignore_users, &user).await;
// check question
let question = match data.0.get_post_question(&post, &ignore_users).await {
let question = match data
.0
.get_post_question(&post, &ignore_users, &mut HashMap::new())
.await
{
Ok(q) => q,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
@ -918,7 +932,11 @@ pub async fn reposts_request(
let reposting = data.0.get_post_reposting(&post, &ignore_users, &user).await;
// check question
let question = match data.0.get_post_question(&post, &ignore_users).await {
let question = match data
.0
.get_post_question(&post, &ignore_users, &mut HashMap::new())
.await
{
Ok(q) => q,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
@ -1069,7 +1087,11 @@ pub async fn likes_request(
.await;
// check question
let question = match data.0.get_post_question(&post, &ignore_users).await {
let question = match data
.0
.get_post_question(&post, &ignore_users, &mut HashMap::new())
.await
{
Ok(q) => q,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};

View file

@ -5,8 +5,8 @@ use axum::{
extract::Path,
Extension,
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{permissions::FinePermission, Error};
use crate::cookie::CookieJar;
use tetratto_core::model::{apps::AppData, permissions::FinePermission, Error};
/// `/developer`
pub async fn home_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
@ -62,9 +62,13 @@ pub async fn app_request(
));
}
let data_limit = AppData::user_limit(&user, &app);
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("app", &app);
context.insert("data_limit", &data_limit);
// return
Ok(Html(data.1.render("developer/app.html", &context).unwrap()))

View file

@ -8,7 +8,7 @@ use axum::{
response::{Html, IntoResponse},
Extension,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use tetratto_core::model::{communities::Community, Error};
/// `/forges`

View file

@ -3,7 +3,7 @@ use axum::{
response::{Html, IntoResponse, Redirect},
Extension,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use crate::{
assets::initial_context,
check_user_blocked_or_private, get_lang, get_user_from_token,
@ -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' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; 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' blob: *; frame-ancestors *",
)],
Html(data.1.render("journals/app.html", &context).unwrap()),
))

View file

@ -1,18 +1,23 @@
use super::render_error;
use crate::{assets::initial_context, get_lang, get_user_from_token, State};
use crate::{
assets::initial_context, get_lang, get_user_from_token,
routes::pages::misc::NotificationsProps, State,
};
use axum::{
response::{Html, IntoResponse},
extract::{Query, Path},
Extension,
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{littleweb::TLDS_VEC, Error};
use crate::cookie::CookieJar;
use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error};
use serde::Deserialize;
use tetratto_shared::hash::salt;
/// `/services`
pub async fn services_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(props): Query<NotificationsProps>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
@ -24,7 +29,26 @@ pub async fn services_request(
}
};
let list = match data.0.get_services_by_user(user.id).await {
let profile = if props.id != 0 {
match data.0.get_user_by_id(props.id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
}
} else {
user.clone()
};
if profile.id != user.id
&& !user
.secondary_permissions
.check(SecondaryPermission::MANAGE_SERVICES)
{
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
let list = match data.0.get_services_by_user(profile.id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
@ -32,6 +56,7 @@ pub async fn services_request(
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("profile", &profile);
// return
Ok(Html(
@ -43,6 +68,7 @@ pub async fn services_request(
pub async fn domains_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(props): Query<NotificationsProps>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
@ -54,7 +80,26 @@ pub async fn domains_request(
}
};
let list = match data.0.get_domains_by_user(user.id).await {
let profile = if props.id != 0 {
match data.0.get_user_by_id(props.id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
}
} else {
user.clone()
};
if profile.id != user.id
&& !user
.secondary_permissions
.check(SecondaryPermission::MANAGE_DOMAINS)
{
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
let list = match data.0.get_domains_by_user(profile.id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
@ -64,6 +109,7 @@ pub async fn domains_request(
context.insert("list", &list);
context.insert("tlds", &*TLDS_VEC);
context.insert("profile", &profile);
// return
Ok(Html(
@ -99,7 +145,11 @@ pub async fn service_request(
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if user.id != service.owner {
if user.id != service.owner
&& !user
.secondary_permissions
.check(SecondaryPermission::MANAGE_SERVICES)
{
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
@ -153,7 +203,11 @@ pub async fn domain_request(
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if user.id != domain.owner {
if user.id != domain.owner
&& !user
.secondary_permissions
.check(SecondaryPermission::MANAGE_DOMAINS)
{
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
@ -177,12 +231,26 @@ pub async fn browser_home_request(
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
// update session
let session = salt();
if let Some(ref ua) = user {
if let Err(e) = data.0.update_user_browser_session(ua.id, &session).await {
return Err(Html(render_error(e.into(), &jar, &data, &None).await));
}
}
// ...
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;
context.insert("path", &"");
context.insert("session", &session);
// return
Html(data.1.render("littleweb/browser.html", &context).unwrap())
Ok(Html(
data.1.render("littleweb/browser.html", &context).unwrap(),
))
}
/// `/net/{uri}`
@ -202,10 +270,24 @@ pub async fn browser_request(
uri = format!("atto://{uri}");
}
// update session
let session = salt();
if let Some(ref ua) = user {
if let Err(e) = data.0.update_user_browser_session(ua.id, &session).await {
return Err(Html(render_error(e.into(), &jar, &data, &None).await));
}
}
// ...
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;
context.insert("path", &uri);
context.insert("session", &session);
context.insert("path", &uri.replace("atto://", ""));
// return
Html(data.1.render("littleweb/browser.html", &context).unwrap())
Ok(Html(
data.1.render("littleweb/browser.html", &context).unwrap(),
))
}

View file

@ -0,0 +1,107 @@
use super::render_error;
use crate::{
assets::initial_context, get_lang, get_user_from_token, State, routes::pages::PaginatedQuery,
};
use axum::{
extract::Query,
response::{Html, IntoResponse},
Extension,
};
use crate::cookie::CookieJar;
use tetratto_core::model::Error;
/// `/settings/seller`
pub async fn seller_settings_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(props): Query<PaginatedQuery>,
) -> 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 products = match data.0.get_products_by_user(user.id, 12, props.page).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &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("list", &products);
context.insert("page", &props.page);
// return
Ok(Html(
data.1.render("marketplace/seller.html", &context).unwrap(),
))
}
pub async fn connection_return_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let mut user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
// update user
user.seller_data.completed_onboarding = true;
if let Err(e) = data
.0
.update_user_seller_data(user.id, user.seller_data.clone())
.await
{
return Err(Html(render_error(e, &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("connection_type", "return");
// return
Ok(Html(
data.1
.render("auth/seller_connection.html", &context)
.unwrap(),
))
}
pub async fn connection_reload_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 lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("connection_type", "reload");
// return
Ok(Html(
data.1
.render("auth/seller_connection.html", &context)
.unwrap(),
))
}

View file

@ -7,7 +7,7 @@ use axum::{
response::{Html, IntoResponse},
Extension,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use serde::Deserialize;
use tetratto_core::model::{
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS},
@ -58,7 +58,7 @@ pub async fn index_request(
let list = match data
.0
.get_posts_from_user_communities(user.id, 12, req.page)
.get_posts_from_user_communities(user.id, 12, req.page, &user)
.await
{
Ok(l) => match data
@ -725,7 +725,7 @@ pub async fn swiss_army_timeline_request(
DefaultTimelineChoice::MyCommunities => {
if let Some(ref ua) = user {
data.0
.get_posts_from_user_communities(ua.id, 12, req.page)
.get_posts_from_user_communities(ua.id, 12, req.page, ua)
.await
} else {
return Err(Html(

View file

@ -5,6 +5,7 @@ pub mod developer;
pub mod forge;
pub mod journals;
pub mod littleweb;
pub mod marketplace;
pub mod misc;
pub mod mod_panel;
pub mod profile;
@ -14,14 +15,13 @@ use axum::{
routing::{get, post},
Router,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use serde::Deserialize;
use tetratto_core::{
DataManager,
model::{Error, auth::User},
};
use crate::{assets::initial_context, get_lang};
use crate::{assets::initial_context, get_lang, InnerState};
pub fn routes() -> Router {
Router::new()
@ -77,6 +77,14 @@ pub fn routes() -> Router {
"/auth/connections_link/app/{id}",
get(developer::connection_callback_request),
)
.route(
"/auth/connections_link/seller/reload",
get(marketplace::connection_reload_request),
)
.route(
"/auth/connections_link/seller/return",
get(marketplace::connection_return_request),
)
// profile
.route("/settings", get(profile::settings_request))
.route("/@{username}", get(profile::posts_request))
@ -147,6 +155,11 @@ pub fn routes() -> Router {
.route("/domains/{id}", get(littleweb::domain_request))
.route("/net", get(littleweb::browser_home_request))
.route("/net/{*uri}", get(littleweb::browser_request))
// marketplace
.route(
"/settings/seller",
get(marketplace::seller_settings_request),
)
}
pub fn lw_routes() -> Router {
@ -156,7 +169,7 @@ pub fn lw_routes() -> Router {
pub async fn render_error(
e: Error,
jar: &CookieJar,
data: &(DataManager, tera::Tera, reqwest::Client),
data: &InnerState,
user: &Option<User>,
) -> String {
let lang = get_lang!(jar, data.0);

View file

@ -5,7 +5,7 @@ use axum::{
response::{Html, IntoResponse},
Extension,
};
use axum_extra::extract::CookieJar;
use crate::cookie::CookieJar;
use serde::Deserialize;
use tetratto_core::{
cache::Cache,

Some files were not shown because too many files have changed in this diff Show more