Compare commits

...

38 commits

Author SHA1 Message Date
83971b3d20 fix: ads balance check 2025-08-11 22:53:38 -04:00
9a5236bc1b fix: wallet page pagination 2025-08-11 20:45:51 -04:00
59bccd9474 add: automatically stop all user ads when user cannot afford transfer 2025-08-11 20:44:16 -04:00
2cb7d08ddc add: user ads 2025-08-11 20:21:05 -04:00
46b3e66cd4 fix: media gallery in apple internet explorer 2025-08-11 12:32:37 -04:00
ba319130d2 add: allow mods to remove associations 2025-08-10 22:26:13 -04:00
bcee3f7763 add: require that users keep supporter status for profilestyle products 2025-08-10 16:20:23 -04:00
59378a4447 add: don't allow free ProfileStyle products 2025-08-10 16:04:21 -04:00
7f0cb1f2a1 fix: user and post deletion 2025-08-10 12:32:20 -04:00
5a9160f612 fix: properly clean up product uploads 2025-08-10 00:19:57 -04:00
7a31dcbd9b add: product thumbnails ui 2025-08-10 00:17:21 -04:00
815e730fc0 fix: timeline loading 2025-08-09 17:47:06 -04:00
9ef9b9e579 add: allow 2 timeline retries instead of 1 2025-08-09 17:33:49 -04:00
e145449bc7 add: timeline build retries 2025-08-09 17:27:33 -04:00
fdaae8d977 add: transfer refunds 2025-08-09 14:00:46 -04:00
95cb889080 add: ProfileStyle products 2025-08-08 23:44:45 -04:00
077e9252e3 add: hide_username_badges 2025-08-08 21:05:56 -04:00
98426d0989 add: store coin transfer source 2025-08-08 16:01:23 -04:00
a08552338b fix: wallet panic 2025-08-08 14:52:53 -04:00
e5e6d5cddb add: single_use products 2025-08-08 14:17:40 -04:00
7fbc732290 fix: remove extra stripe payment link config 2025-08-08 13:29:01 -04:00
44f9edd67e add: coin purchases + donator badge 2025-08-08 13:25:47 -04:00
fd529d3847 add: products ui 2025-08-08 02:17:06 -04:00
8f76578f1b fix: pinned posts panic 2025-08-07 13:54:34 -04:00
df5eaf24f7 add: products api 2025-08-07 13:52:48 -04:00
3c4ce1fae5 add: economy api 2025-08-07 00:22:37 -04:00
0a3ce3e9fe add: user coins and transactions 2025-08-06 23:35:13 -04:00
81a7628861 add: better pinned posts ui 2025-08-06 23:13:31 -04:00
b5f841a990 remove: marketplace 2025-08-05 23:50:45 -04:00
2407e6b213 add: ability to convert existing communities into forums 2025-08-05 22:36:25 -04:00
3958d5eaef add: forum threads ui 2025-08-05 16:33:53 -04:00
155fe34c6e add: temporary bans 2025-08-05 13:39:01 -04:00
9650c0177e fix: trim forum post titles 2025-08-04 23:29:24 -04:00
80a39e7489 fix: posts without topic 2025-08-04 15:06:08 -04:00
548a6dcf4e add: town square forum config 2025-08-04 14:58:36 -04:00
d4ff681310 add: forum posts timeline 2025-08-04 14:24:25 -04:00
8c779b2f2e add: individual topic write permissions 2025-08-04 13:23:27 -04:00
3738a5cd1f fix: forum signatures 2025-08-04 12:57:00 -04:00
109 changed files with 5637 additions and 1296 deletions

4
Cargo.lock generated
View file

@ -3318,7 +3318,7 @@ dependencies = [
[[package]]
name = "tetratto"
version = "14.0.0"
version = "15.0.0"
dependencies = [
"ammonia",
"async-stripe",
@ -3350,7 +3350,7 @@ dependencies = [
[[package]]
name = "tetratto-core"
version = "14.0.0"
version = "15.0.0"
dependencies = [
"async-recursion",
"base16ct",

View file

@ -10,9 +10,9 @@ package.homepage = "https://tetratto.com"
incremental = true
[profile.release]
opt-level = "z"
opt-level = 3
lto = true
codegen-units = 1
codegen-units = 2
# panic = "abort"
panic = "unwind"
strip = true

View file

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

View file

@ -38,6 +38,7 @@ pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
pub const CARP_JS: &str = include_str!("./public/js/carp.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");
pub const ADS_JS: &str = include_str!("./public/js/ads.js");
// html
pub const BODY: &str = include_str!("./public/html/body.lisp");
@ -55,7 +56,6 @@ 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");
@ -70,6 +70,7 @@ pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.li
pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp");
pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp");
pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp");
pub const PROFILE_SHOP: &str = include_str!("./public/html/profile/shop.lisp");
pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp");
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp");
@ -88,6 +89,8 @@ pub const POST_POST: &str = include_str!("./public/html/post/post.lisp");
pub const POST_REPOSTS: &str = include_str!("./public/html/post/reposts.lisp");
pub const POST_QUOTES: &str = include_str!("./public/html/post/quotes.lisp");
pub const POST_LIKES: &str = include_str!("./public/html/post/likes.lisp");
pub const POST_FORUM_QUICK_REPLIES: &str =
include_str!("./public/html/post/forum_quick_replies.lisp");
pub const TIMELINES_HOME: &str = include_str!("./public/html/timelines/home.lisp");
pub const TIMELINES_POPULAR: &str = include_str!("./public/html/timelines/popular.lisp");
@ -103,6 +106,8 @@ pub const TIMELINES_ALL_QUESTIONS: &str =
include_str!("./public/html/timelines/all_questions.lisp");
pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.lisp");
pub const TIMELINES_SWISS_ARMY: &str = include_str!("./public/html/timelines/swiss_army.lisp");
pub const TIMELINES_ALL_FORUM_POSTS: &str =
include_str!("./public/html/timelines/all_forum_posts.lisp");
pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.lisp");
pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.lisp");
@ -139,13 +144,18 @@ 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");
pub const MAIL_RECEIVED: &str = include_str!("./public/html/mail/received.lisp");
pub const MAIL_SENT: &str = include_str!("./public/html/mail/sent.lisp");
pub const MAIL_COMPOSE: &str = include_str!("./public/html/mail/compose.lisp");
pub const MAIL_LETTER: &str = include_str!("./public/html/mail/letter.lisp");
pub const ECONOMY_WALLET: &str = include_str!("./public/html/economy/wallet.lisp");
pub const ECONOMY_PRODUCTS: &str = include_str!("./public/html/economy/products.lisp");
pub const ECONOMY_EDIT: &str = include_str!("./public/html/economy/edit.lisp");
pub const ECONOMY_PRODUCT: &str = include_str!("./public/html/economy/product.lisp");
pub const ECONOMY_EDIT_AD: &str = include_str!("./public/html/economy/edit_ad.lisp");
pub const ECONOMY_AD: &str = include_str!("./public/html/economy/ad.lisp");
// langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
@ -293,7 +303,6 @@ 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);
@ -308,6 +317,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins);
write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins);
write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins);
write_template!(html_path->"profile/shop.html"(crate::assets::PROFILE_SHOP) --config=config --lisp plugins);
write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins);
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins);
@ -325,6 +335,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"post/reposts.html"(crate::assets::POST_REPOSTS) --config=config --lisp plugins);
write_template!(html_path->"post/quotes.html"(crate::assets::POST_QUOTES) --config=config --lisp plugins);
write_template!(html_path->"post/likes.html"(crate::assets::POST_LIKES) --config=config --lisp plugins);
write_template!(html_path->"post/forum_quick_replies.html"(crate::assets::POST_FORUM_QUICK_REPLIES) --config=config --lisp plugins);
write_template!(html_path->"timelines/home.html"(crate::assets::TIMELINES_HOME) -d "timelines" --config=config --lisp plugins);
write_template!(html_path->"timelines/popular.html"(crate::assets::TIMELINES_POPULAR) --config=config --lisp plugins);
@ -336,6 +347,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config --lisp plugins);
write_template!(html_path->"timelines/search.html"(crate::assets::TIMELINES_SEARCH) --config=config --lisp plugins);
write_template!(html_path->"timelines/swiss_army.html"(crate::assets::TIMELINES_SWISS_ARMY) --config=config --lisp plugins);
write_template!(html_path->"timelines/all_forum_posts.html"(crate::assets::TIMELINES_ALL_FORUM_POSTS) --config=config --lisp plugins);
write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config --lisp plugins);
write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config --lisp plugins);
@ -372,13 +384,18 @@ 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);
write_template!(html_path->"mail/received.html"(crate::assets::MAIL_RECEIVED) -d "mail" --config=config --lisp plugins);
write_template!(html_path->"mail/sent.html"(crate::assets::MAIL_SENT) --config=config --lisp plugins);
write_template!(html_path->"mail/compose.html"(crate::assets::MAIL_COMPOSE) --config=config --lisp plugins);
write_template!(html_path->"mail/letter.html"(crate::assets::MAIL_LETTER) --config=config --lisp plugins);
write_template!(html_path->"economy/wallet.html"(crate::assets::ECONOMY_WALLET) -d "economy" --config=config --lisp plugins);
write_template!(html_path->"economy/products.html"(crate::assets::ECONOMY_PRODUCTS) --config=config --lisp plugins);
write_template!(html_path->"economy/edit.html"(crate::assets::ECONOMY_EDIT) --config=config --lisp plugins);
write_template!(html_path->"economy/product.html"(crate::assets::ECONOMY_PRODUCT) --config=config --lisp plugins);
write_template!(html_path->"economy/edit_ad.html"(crate::assets::ECONOMY_EDIT_AD) --config=config --lisp plugins);
write_template!(html_path->"economy/ad.html"(crate::assets::ECONOMY_AD) --config=config --lisp plugins);
html_path
}

View file

@ -20,6 +20,7 @@ version = "1.0.0"
"general:link.achievements" = "Achievements"
"general:link.little_web" = "Little web"
"general:link.mail" = "Mail"
"general:link.wallet" = "Wallet"
"general:action.save" = "Save"
"general:action.delete" = "Delete"
"general:action.purge" = "Purge"
@ -50,6 +51,8 @@ version = "1.0.0"
"general:label.loading" = "Working on it!"
"general:label.send_anonymously" = "Send anonymously"
"general:label.must_activate_account" = "You need to activate your account!"
"general:action.load_more" = "Load more"
"general:action.show_thread" = "Show thread"
"general:label.supporter_motivation" = "Become a supporter!"
"general:action.become_supporter" = "Become supporter"
@ -85,6 +88,7 @@ version = "1.0.0"
"auth:label.replies" = "Replies"
"auth:label.media" = "Media"
"auth:label.outbox" = "Outbox"
"auth:label.shop" = "Shop"
"auth:label.before_you_view" = "Before you view"
"auth:label.private_profile" = "Private profile"
"auth:label.private_profile_message" = "This profile is private, meaning you can only view it if they follow you."
@ -116,7 +120,9 @@ version = "1.0.0"
"communities:label.content" = "Content"
"communities:label.title" = "Title"
"communities:label.posts" = "Posts"
"communities:label.forum_posts" = "Forum posts"
"communities:label.topics" = "Topics"
"communities:label.topic" = "Topic"
"communities:label.questions" = "Questions"
"communities:label.not_allowed_to_read" = "You're not allowed to view this community's posts"
"communities:label.might_need_to_join" = "You might need to join this community in order to interact with it!"
@ -202,6 +208,7 @@ version = "1.0.0"
"settings:tab.billing" = "Billing"
"settings:tab.uploads" = "Uploads"
"settings:tab.invites" = "Invites"
"setttings:label.applied_configurations" = "Applied configurations"
"mod_panel:label.open_reported_content" = "Open reported content"
"mod_panel:label.manage_profile" = "Manage profile"
@ -212,6 +219,7 @@ version = "1.0.0"
"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:label.ban_expiration" = "Ban expiration"
"requests:label.requests" = "Requests"
"requests:label.community_join_request" = "Community join request"
@ -221,6 +229,7 @@ version = "1.0.0"
"requests:label.user_follow_request" = "User follow request"
"requests:action.view_profile" = "View profile"
"requests:label.user_follow_request_message" = "Accepting this request will not allow them to see your profile. For that, you must follow them back."
"requests:label.coin_transfer_request" = "Coin transfer request"
"chats:label.my_chats" = "My chats"
"chats:action.move" = "Move"
@ -311,12 +320,6 @@ version = "1.0.0"
"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"
"mail:label.received" = "Received"
"mail:label.sent" = "Sent"
"mail:label.compose" = "Compose"
@ -325,3 +328,32 @@ version = "1.0.0"
"mail:label.content" = "Content"
"mail:action.send" = "Send"
"mail:action.send_mail" = "Send mail"
"economy:label.recent_transfers" = "Recent transfers"
"economy:action.request" = "Request"
"economy:label.title" = "Title"
"economy:label.description" = "Description"
"economy:label.my_products" = "My products"
"economy:label.my_wallet" = "My wallet"
"economy:label.create_new" = "Create new product"
"economy:label.price" = "Price"
"economy:label.on_sale" = "On sale"
"economy:label.single_use" = "Only allow users to purchase once"
"economy:label.stock" = "Stock"
"economy:label.unlimited" = "Unlimited"
"economy:label.fulfillment_style" = "Fulfillment style"
"economy:label.use_automail" = "Use automail"
"economy:label.automail_message" = "Automail message"
"economy:action.buy" = "Buy"
"economy:label.already_purchased" = "Already purchased"
"economy:label.snippet_data" = "Snippet data"
"economy:action.apply" = "Apply"
"economy:action.unapply" = "Unapply"
"economy:label.thumbnails" = "Thumbnails"
"economy:label.my_ads" = "My ads"
"economy:label.create_new_ad" = "Create new advertisement"
"economy:label.target" = "Target URL"
"economy:label.image" = "Image"
"economy:label.size_base" = "Size base"
"economy:label.running" = "Running"
"economy:label.embed_ads_on_my_site" = "Embed ads on my site"

View file

@ -87,10 +87,31 @@ macro_rules! get_user_from_token {
{
Ok(ua) => {
if ua.permissions.check_banned() {
let mut banned_user = tetratto_core::model::auth::User::banned();
banned_user.ban_reason = ua.ban_reason;
// check expiration
let now = tetratto_shared::unix_epoch_timestamp();
let expired = ua.ban_expire <= now;
Some(banned_user)
if expired && ua.ban_expire != 0 {
$db.update_user_role(
ua.id,
ua.permissions
- tetratto_core::model::permissions::FinePermission::BANNED,
&ua,
true,
)
.await
.expect("failed to auto unban user");
Some(ua)
} else {
// banned
let mut banned_user = tetratto_core::model::auth::User::banned();
banned_user.ban_reason = ua.ban_reason;
banned_user.ban_expire = ua.ban_expire;
Some(banned_user)
}
} else {
Some(ua)
}

View file

@ -5,22 +5,26 @@
--hue: 16;
--sat: 6%;
--lit: 0%;
--color-surface: hsl(var(--hue), var(--sat), calc(97% - var(--lit)));
--color-lowered: hsl(var(--hue), var(--sat), calc(94% - var(--lit)));
--color-raised: hsl(var(--hue), var(--sat), calc(99% - var(--lit)));
--color-surface: hsl(var(--hue), var(--sat), calc(94% - var(--lit)));
--color-lowered: hsl(var(--hue), var(--sat), calc(90% - var(--lit)));
--color-raised: hsl(var(--hue), var(--sat), calc(97% - var(--lit)));
--color-super-lowered: hsl(var(--hue), var(--sat), calc(85% - var(--lit)));
--color-super-raised: hsl(var(--hue), var(--sat), calc(100% - var(--lit)));
--color-text: hsl(0, 0%, 0%);
--color-super-raised: hsl(var(--hue), var(--sat), calc(99% - var(--lit)));
--color-text: hsl(0, 0%, 9%);
--color-text-raised: var(--color-text);
--color-text-lowered: var(--color-text);
--color-primary: hsl(330, 18%, 26%);
--color-primary-lowered: hsl(330, 18%, 21%);
--color-text-primary: hsl(0, 0%, 100%);
--color-text-primary: hsl(0, 0%, 91%);
--color-secondary: hsl(6, 18%, 66%);
--color-secondary-lowered: hsl(6, 18%, 61%);
--color-text-secondary: hsl(0, 0%, 0%);
--color-secondary: hsl(277, 27%, 70%);
--color-secondary-lowered: hsl(277, 27%, 65%);
--color-text-secondary: hsl(0, 0%, 9%);
--color-accent: hsl(237, 27%, 28%);
--color-accent-lowered: hsl(237, 27%, 23%);
--color-text-accent: hsl(0, 0%, 91%);
--color-link: #2949b2;
--color-shadow: rgba(0, 0, 0, 0.08);
@ -54,15 +58,19 @@
--color-raised: hsl(var(--hue), var(--sat), calc(2% + var(--lit)));
--color-super-lowered: hsl(var(--hue), var(--sat), calc(12% + var(--lit)));
--color-super-raised: hsl(var(--hue), var(--sat), calc(4% + var(--lit)));
--color-text: hsl(0, 0%, 95%);
--color-text: hsl(0, 0%, 91%);
--color-primary: hsl(331, 18%, 74%);
--color-primary-lowered: hsl(331, 18%, 69%);
--color-text-primary: hsl(0, 0%, 0%);
--color-text-primary: hsl(0, 0%, 9%);
--color-secondary: hsl(6, 18%, 34%);
--color-secondary-lowered: hsl(6, 18%, 29%);
--color-text-secondary: hsl(0, 0%, 100%);
--color-secondary: hsl(277, 27%, 30%);
--color-secondary-lowered: hsl(277, 27%, 25%);
--color-text-secondary: hsl(0, 0%, 91%);
--color-accent: hsl(237, 27%, 72%);
--color-accent-lowered: hsl(237, 27%, 67%);
--color-text-accent: hsl(0, 0%, 9%);
--color-link: #93c5fd;
--color-red: hsl(0, 94%, 82%);
@ -193,8 +201,7 @@ p {
.name {
max-width: 250px;
overflow: hidden;
/* overflow-wrap: break-word; */
overflow-wrap: anywhere;
white-space: nowrap;
text-overflow: ellipsis;
}
@ -242,7 +249,8 @@ svg.icon {
height: 1em;
}
svg.icon.filled {
svg.icon.filled,
.filled svg.icon {
fill: currentColor;
}

View file

@ -1,5 +1,11 @@
@import url("root.css");
/* ads */
.tetratto_ad iframe {
border-radius: var(--radius);
}
/* media gallery */
.media_gallery {
display: grid;
grid-auto-columns: 1fr 1fr;
@ -11,7 +17,9 @@
@media screen and (max-width: 900px) {
.media_gallery {
grid-auto-flow: row dense;
/* grid-auto-flow: row dense; */ /* safari is the most shit browser ever dude, this property causes safari to make images overlap for lord knows why */
display: flex;
flex-direction: column;
}
}
@ -271,6 +279,16 @@ table ol {
}
}
.card.button {
justify-content: flex-start;
align-items: flex-start;
flex-direction: column;
gap: var(--pad-2);
width: 100%;
height: max-content;
padding: var(--pad-4);
}
/* supporter card */
@property --border-angle {
syntax: "<angle>";
@ -312,7 +330,7 @@ button,
transition: background 0.15s;
width: max-content;
height: 32px;
padding: var(--pad-1) var(--pad-4);
padding: 0 var(--pad-4);
border-radius: var(--radius);
cursor: pointer;
display: flex;
@ -369,7 +387,17 @@ button.secondary,
.button.secondary {
background: var(--color-secondary);
color: var(--color-text-secondary);
font-weight: 500;
}
button.accent:hover,
.button.accent:hover {
background: var(--color-accent-lowered);
}
button.accent,
.button.accent {
background: var(--color-accent);
color: var(--color-text-accent);
}
button.secondary:hover,
@ -439,7 +467,6 @@ textarea,
select {
padding: 0.35rem var(--pad-3);
border-radius: var(--radius);
border: solid 1px var(--color-super-lowered);
outline: none;
transition: background 0.15s;
resize: vertical;
@ -447,8 +474,10 @@ select {
font-family: inherit;
font-size: 16px;
/* personality */
background: transparent;
color: inherit;
--background: var(--color-lowered);
border: solid 1px var(--background);
background: var(--background);
color: var(--color-text-lowered);
}
textarea {
@ -458,8 +487,14 @@ textarea {
input:focus,
textarea:focus,
select:focus {
background: var(--color-super-raised);
color: var(--color-text-raised);
border-color: var(--color-super-lowered);
}
input:disabled,
textarea:disabled,
select:disabled {
opacity: 50%;
cursor: not-allowed;
}
.poll_bar {
@ -491,7 +526,7 @@ select:focus {
}
input[type="checkbox"] {
--color: #c9b1bc;
--color: #93c5fd;
appearance: none;
border-radius: var(--radius);
transition:
@ -528,6 +563,10 @@ input[type="checkbox"]:checked {
background-image: url("/icons/check.svg");
}
label {
cursor: pointer;
}
/* pillmenu */
.pillmenu {
display: flex;
@ -860,18 +899,19 @@ dialog {
display: none;
background: var(--color-surface);
border: solid 1px var(--color-super-lowered) !important;
border-radius: var(--radius);
max-width: 100%;
border-radius: calc(var(--radius) * 2);
max-width: 95%;
border-style: none;
margin: auto;
color: var(--color-text);
animation: popin ease-in-out 1 0.1s forwards running;
animation: popin ease-in-out 1 0.15s forwards running;
}
dialog .inner {
padding: var(--pad-4);
width: 25rem;
max-width: 100%;
font-weight: 500;
}
dialog .inner hr:not(.flipped):last-of-type {
@ -890,6 +930,11 @@ dialog[open] {
dialog::backdrop {
background: hsla(0, 0%, 0%, 50%);
backdrop-filter: blur(5px);
animation: fadein ease-in-out 1 0.1s forwards running;
}
dialog:is(.dark *)::backdrop {
background: hsla(0, 0%, 100%, 15%);
}
/* dropdown */
@ -902,9 +947,9 @@ dialog::backdrop {
display: none;
position: absolute;
background: var(--color-raised);
border: solid 1px var(--color-super-lowered);
/* border: solid 1px var(--color-super-lowered); */
z-index: 2;
border-radius: var(--radius);
border-radius: calc(var(--radius) * 2);
top: calc(100% + 5px);
right: 0;
width: max-content;
@ -1404,3 +1449,55 @@ details.accordion .inner {
background-color: var(--color-primary) !important;
color: var(--color-text-primary) !important;
}
/* threads */
.squig {
--color: var(--color-super-lowered);
--background: var(--color-raised);
--size: 10px;
position: relative;
width: 100%;
height: 20px;
display: block;
}
.squig::before,
.squig::after {
content: "";
position: absolute;
width: inherit;
height: inherit;
background-size: var(--size) 100%;
}
.squig::before {
top: -2px;
background-image:
linear-gradient(45deg, var(--color) 35%, transparent 0),
linear-gradient(-45deg, var(--color) 35%, transparent 0);
}
.squig::after {
top: 0px;
background-image:
linear-gradient(45deg, var(--background) 35%, transparent 0),
linear-gradient(-45deg, var(--background) 35%, transparent 0);
}
.thread {
--pad: 15px;
--squig-height: 20px;
position: relative;
padding-left: var(--pad);
}
.thread::before {
content: "";
position: absolute;
background: var(--color-super-lowered);
height: calc(100% - var(--squig-height));
width: 5px;
left: 0;
top: 0;
border-radius: var(--radius);
}

View file

@ -35,7 +35,7 @@
justify-content: right;
}
.justify-start {
.justify_start {
justify-content: flex-start !important;
}

View file

@ -3,7 +3,7 @@
("class" "flex flex_col gap_2")
("style" "max-width: 48ch")
(h2
("class" "w_full text-center")
("class" "w_full text_center")
; block for title
(text "{% block title %}{% endblock %}"))
(div

View file

@ -113,10 +113,9 @@
(text "{% endblock %} {% block footer %}")
(span
("class" "small w_full text-center")
("class" "small w_full text_center")
(text "Or, ")
(a
("href" "/auth/register")
(text "register")))
(text "{% endblock %}")

View file

@ -170,10 +170,9 @@
(text "{% endblock %} {% block footer %}")
(span
("class" "small w_full text-center")
("class" "small w_full text_center")
(text "Or, ")
(a
("href" "/auth/login")
(text "login")))
(text "{% endblock %}")

View file

@ -1,25 +0,0 @@
(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

@ -1,5 +1,10 @@
(div ("id" "toast_zone"))
; ads
(script ("src" "/js/ads.js?v=tetratto-{{ random_cache_breaker }}"))
(script
(text "TetrattoAds.init();"))
; large text
(text "{% if user and user.settings.large_text -%}")
(style
@ -76,6 +81,8 @@
return;
}
TetrattoAds.render_ads(\"{{ config.system_user }}\", \"\");
atto.disconnect_observers();
atto.remove_false_options();
atto.clean_date_codes();
@ -169,21 +176,20 @@
("id" "littleweb")
(div
("class" "inner flex flex_col gap_2")
(a
("class" "button w_full lowered justify-start")
("class" "button w_full lowered justify_start")
("href" "/net")
(icon (text "globe"))
(str (text "littleweb:label.browser")))
(a
("class" "button w_full lowered justify-start")
("class" "button w_full lowered justify_start")
("href" "/services")
(icon (text "panel-top"))
(str (text "littleweb:label.my_services")))
(a
("class" "button w_full lowered justify-start")
("class" "button w_full lowered justify_start")
("href" "/domains")
(icon (text "panel-top"))
(str (text "littleweb:label.my_domains")))
@ -311,7 +317,7 @@
(div ("id" "tokens") ("style" "display: contents"))
(div
("class" "flex justify_between")
("class" "flex justify_right gap_2")
(a
("href" "/auth/login")
("class" "button")
@ -323,7 +329,8 @@
("class" "lowered")
("onclick" "document.getElementById('tokens_dialog').close()")
("type" "button")
(icon (text "check")))))))
(icon (text "check"))
(str (text "dialog:action.okay")))))))
; user scripts
(text "{%- endif %} {% if user and use_user_theme -%} {{ components::theme(user=user, theme_preference=user.settings.theme_preference) }}

View file

@ -73,7 +73,7 @@
(text "{%- endif %}"))
(text "{% if can_manage_channels -%}")
(a
("class" "button w_full justify-start lowered")
("class" "button w_full justify_start lowered")
("href" "/community/{{ selected_community }}/manage#/channels")
(text "{{ icon \"plus\" }}")
(span

View file

@ -7,7 +7,7 @@
(div
("class" "flex flex_row gap_1")
(a
("class" "w_full justify-start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}")
("class" "w_full justify_start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}")
("href" "/chats/{{ selected_community }}/{{ channel.id }}")
("data-turbo" "{{ selected_community == '0' }}")
(text "{{ icon \"rss\" }}")

View file

@ -109,6 +109,23 @@
("class" "card flex flex_col gap_2")
("id" "create_form")
("onsubmit" "create_post_from_form(event)")
(text "{% if show_topics -%}")
(div
("class" "flex flex_col gap_1")
(label
("for" "topic")
(str (text "communities:label.topic")))
(select
("id" "topic")
("name" "topic")
(text "{% for id, topic in topics %}")
(option
("value" "{{ id }}")
("selected" "{% if selected_topic|int == id|int -%}true{% else %}false{%- endif %}")
(text "{{ topic.title }}"))
(text "{% endfor %}")))
(text "{%- endif %}")
(div
("class" "flex flex_col gap_1 hidden")
("id" "title_field")
@ -134,7 +151,12 @@
("placeholder" "content")
("minlength" "2")
("maxlength" "4096")
(text "{% if draft -%}{{ draft.content }}{%- endif %}")))
(text "{% if draft -%}{{ draft.content }}{%- endif %}")
(text "{%- if use_signature %}")
(text "
{{ user.settings.forum_signature }}")
(text "{%- endif %}")))
(div
("id" "files_list")
("class" "flex gap_2 flex_wrap"))
@ -160,11 +182,10 @@
(text "{%- endif %} {%- endif %}")
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))))
(str (text "communities:action.create")))))))
(text "{% if not quoting -%}")
(script
(text "globalThis.SEARCH_PARAMS = new URLSearchParams(window.location.search);
async function create_post_from_form(e) {
(text "async function create_post_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"posts::create\"]);
@ -205,7 +226,7 @@
content: e.target.content.value,
community: !is_selected_stack ? selected_community : \"0\",
stack: is_selected_stack ? selected_community : \"0\",
topic: !is_selected_stack ? SEARCH_PARAMS.get(\"topic\") || \"0\" : \"0\",
topic: (e.target.topic || { selectedOptions: [{ value: \"0\" }] }).selectedOptions[0].value,
poll: poll_data[1],
title: e.target.title.value,
}),
@ -452,14 +473,19 @@
}
setTimeout(() => {
update_community_avatar({
target: document.getElementById(\"community_to_post_to\"),
});
const fake_select = {
parentElement: document.getElementById(\"community_to_post_to\").parentElement,
selectedOptions: [{
value: \"{{ selected_community }}\",
getAttribute() {
return \"{{ selected_stack != 0 }}\";
}
}],
};
check_community_supports_title({
target: document.getElementById(\"community_to_post_to\"),
});
}, 250);
update_community_avatar({ target: fake_select });
check_community_supports_title({ target: fake_select });
}, 150);
window.cancel_create_post = async () => {
if (

View file

@ -1,18 +1,7 @@
(text "{% import \"components.html\" as components %} {% extends \"communities/base.html\" %} {% block content %}")
(div
("class" "flex flex_col gap_4 w_full")
(text "{{ macros::community_nav(community=community, selected=\"posts\") }} {% if pinned|length != 0 %}")
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(text "{{ icon \"pin\" }}")
(span
(text "{{ text \"communities:label.pinned\" }}")))
(div
("class" "card flex flex_col gap_4")
(text "{% for post in pinned %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %}")))
(text "{%- endif %}")
(text "{{ macros::community_nav(community=community, selected=\"posts\") }}")
(div
("class" "card_nest")
(div
@ -22,5 +11,6 @@
(text "{{ text \"communities:label.posts\" }}")))
(div
("class" "card flex flex_col gap_4")
(text "{% for post in pinned %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %}")
(text "{% for post in feed %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=feed|length) }}"))))
(text "{% endblock %}")

View file

@ -37,9 +37,9 @@
("name" "is_forum")
("class" "w_content"))
(span
(text "Is forum")))
(text "Make this a forum community")))
(button
(text "{{ text \"communities:action.create\" }}"))))
(str (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
("class" "card_nest w_full")

View file

@ -1,7 +1,6 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Community settings - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex_col gap_2")
@ -37,14 +36,14 @@
(text "{{ icon \"rss\" }}")
(span
(text "{{ text \"communities:tab.channels\" }}")))
(text "{%- endif %} {% if community.is_forum -%}")
(text "{%- endif %}")
(a
("href" "#/topics")
("data-tab-button" "topics")
(icon (text "list"))
(span
(str (text "communities:tab.topics"))))
(text "{%- endif %} {% if can_manage_emojis -%}")
(text "{% if can_manage_emojis -%}")
(a
("href" "#/emojis")
("data-tab-button" "emojis")
@ -303,7 +302,7 @@
("minlength" "2")
("maxlength" "32")))
(button
(text "{{ text \"communities:action.create\" }}"))))
(str (text "communities:action.create")))))
(text "{% for channel in channels %}")
(div
("class" "card_nest")
@ -447,7 +446,7 @@
(text "{{ text \"communities:label.upload\" }}")))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "upload_emoji(event)")
("onsubmit" "create_emoji_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
@ -473,11 +472,10 @@
("accept" "image/png,image/jpeg,image/avif,image/webp")
("class" "w_full")))
(button
(text "{{ text \"communities:action.create\" }}"))
(str (text "communities:action.create")))
(span
("class" "fade")
(text "Emojis can be a maximum of 256 KiB, or 512x512px (width x
height)."))))
(text "Emojis can be a maximum of 256 KiB."))))
(text "{% for emoji in emojis %}")
(div
("class" "card secondary flex flex_wrap gap_2 items_center justify_between")
@ -506,7 +504,7 @@
(text "{{ text \"stacks:label.remove\" }}")))))
(text "{% endfor %}"))
(script
(text "globalThis.upload_emoji = (e) => {
(text "globalThis.create_emoji_from_form = (e) => {
e.preventDefault();
e.target.querySelector(\"button\").style.display = \"none\";
@ -525,6 +523,10 @@
]);
e.target.querySelector(\"button\").removeAttribute(\"style\");
if (res.ok) {
e.target.reset();
}
});
alert(\"Emoji upload in progress. Please wait!\");
@ -644,7 +646,7 @@
("min" "0")
("max" "256")))
(button
(text "{{ text \"communities:action.create\" }}"))))
(str (text "communities:action.create")))))
(text "{% for id, topic in community.topics %}")
(div
("class" "card_nest")
@ -686,12 +688,12 @@
(div
("class" "flex flex_col gap_1")
(label
("for" "description")
("for" "{{ id }}-description")
(str (text "communities:label.description")))
(input
("type" "text")
("name" "description")
("id" "description")
("id" "{{ id }}-description")
("placeholder" "description")
("value" "{{ topic.description }}")
("required" "")
@ -700,12 +702,12 @@
(div
("class" "flex flex_col gap_1")
(label
("for" "color")
("for" "{{ id }}-color")
(str (text "communities:label.color")))
(input
("type" "color")
("name" "color")
("id" "color")
("id" "{{ id }}-color")
("placeholder" "color")
("required" "")
("value" "{{ topic.color }}")
@ -713,17 +715,37 @@
(div
("class" "flex flex_col gap_1")
(label
("for" "position")
("for" "{{ id }}-position")
(str (text "communities:label.position")))
(input
("type" "number")
("name" "position")
("id" "position")
("id" "{{ id }}-position")
("placeholder" "position")
("required" "")
("value" "{{ topic.position }}")
("min" "0")
("max" "256")))
(div
("class" "flex flex_col gap_1")
(label
("for" "{{ id }}-write_access")
(text "Post permission"))
(select
("name" "write_access")
("id" "{{ id }}-write_access")
(option
("value" "Everybody")
("selected" "{% if topic.write_access == 'Everybody' -%}true{% else %}false{%- endif %}")
(text "Everybody"))
(option
("value" "Joined")
("selected" "{% if topic.write_access == 'Joined' -%}true{% else %}false{%- endif %}")
(text "Joined"))
(option
("value" "Owner")
("selected" "{% if topic.write_access == 'Owner' -%}true{% else %}false{%- endif %}")
(text "Owner only"))))
(button
(icon (text "check"))
(str (text "general:action.save")))))))
@ -794,6 +816,7 @@
description: e.target.description.value,
color: e.target.color.value,
position: Number.parseInt(e.target.position.value),
write_access: e.target.write_access.selectedOptions[0].value,
}),
})
.then((res) => res.json())
@ -804,6 +827,42 @@
]);
});
}"))
(text "{% else %}")
(div
("class" "card lowered w_full hidden flex flex_col gap_2")
("data-tab" "topics")
(p (text "You can only manage topics for forum communities. You can convert this community into a forum, but you will not be able to go back."))
(p (text "This will permanently change your community. Currently existing posts will no longer be visible on the community."))
(button
("onclick" "convert_to_forum()")
(icon (text "circle-fading-arrow-up"))
(text "Switch to forum")))
(script
(text "globalThis.convert_to_forum = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? It cannot be undone.\",
]))
) {
return;
}
fetch(\"/api/v1/communities/{{ community.id }}/is_forum\", {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}"))
(text "{%- endif %}"))
(script

View file

@ -9,13 +9,15 @@
(text "{{ components::topic_display(id=topic_id, topic=topic, community=community, show_description=false) }}")
(div
("class" "flex gap_2")
(text "{% if can_post -%}")
(a
("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}")
("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}&sig=true&topics=true")
("class" "button small lowered")
("data-turbo" "false")
(icon (text "plus"))
(span
(str (text "general:action.post"))))
(text "{%- endif %}")
(a
("href" "/community/{{ community.title }}")
("class" "button lowered small")

View file

@ -6,7 +6,6 @@
("class" "avatar shadow")
("loading" "lazy")
("style" "--size: {{ size }}"))
(text "{%- endmacro %} {% macro community_avatar(id, community=false, size=\"24px\") -%} {% if community -%}")
(img
("src" "/api/v1/communities/{{ id }}/avatar")
@ -14,7 +13,6 @@
("class" "avatar shadow")
("loading" "lazy")
("style" "--size: {{ size }}"))
(text "{% else %}")
(img
("src" "/api/v1/communities/{{ id }}/avatar")
@ -22,7 +20,6 @@
("class" "avatar shadow")
("loading" "lazy")
("style" "--size: {{ size }}"))
(text "{%- endif %} {%- endmacro %} {% macro banner(username, border_radius=\"var(--radius)\") -%}")
(img
("title" "{{ username }}'s banner")
@ -31,21 +28,18 @@
("class" "banner shadow w_full")
("loading" "lazy")
("style" "border-radius: {{ border_radius }};"))
(text "{%- endmacro %} {% macro community_banner(id, community=false) -%} {% if community %}")
(img
("src" "/api/v1/communities/{{ id }}/banner")
("alt" "{{ community.title }}'s banner")
("class" "banner shadow")
("loading" "lazy"))
(text "{% else %}")
(img
("src" "/api/v1/communities/{{ id }}/banner")
("alt" "{{ id }}'s banner")
("class" "banner shadow")
("loading" "lazy"))
(text "{%- endif %} {%- endmacro %} {% macro community_listing_card(community) -%}")
(a
("class" "card secondary w_full flex items_center gap_4")
@ -69,12 +63,10 @@
(b
(text "{{ community.member_count }} "))
(text "members"))))
(text "{%- endmacro %} {% macro username(user) -%}")
(div
("style" "display: contents")
(text "{% if user.settings.display_name -%} {{ user.settings.display_name }} {% else %} {{ user.username }} {%- endif %}"))
(text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false, disable_dislikes=false) -%}")
(button
("title" "Like")
@ -85,7 +77,6 @@
(span
(text "{{ likes }}"))
(text "{%- endif %}"))
(text "{% if not user or not user.settings.hide_dislikes and not disable_dislikes -%}")
(button
("title" "Dislike")
@ -96,21 +87,27 @@
(span
(text "{{ dislikes }}"))
(text "{%- endif %}"))
(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}")
(text "{%- endif %} {%- endmacro %} {% macro full_username(user, wrap=true, max_width=\"180px\") -%} {% if user and user.username -%}")
(div
("class" "flex items_center")
("class" "flex {% if wrap -%} flex_wrap {%- endif %} items_center")
(a
("href" "/@{{ user.username }}")
("class" "flush")
("style" "font-weight: 600")
("class" "name flush flex gap_1")
("style" "font-weight: 600; max-width: {{ max_width }}")
("target" "_top")
(text "{% if user.settings.private_profile -%}")
(span
("title" "Private")
("class" "flex items_center")
(icon (text "lock")))
(text "{%- endif %}")
(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 -%}")
(text "{{ self::online_indicator(user=user) }} {% if not user.settings.hide_username_badges -%} {% if user.is_verified -%}")
(span
("title" "Verified")
("style" "color: var(--color-primary)")
@ -122,19 +119,24 @@
("style" "color: var(--color-primary);")
("class" "flex items_center")
(text "{{ icon \"star\" }}"))
(text "{%- endif %} {% if user.checkouts|length > 0 -%}")
(span
("title" "Donator")
("style" "color: var(--color-primary);")
("class" "flex items_center")
(text "{{ icon \"hand-heart\" }}"))
(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 %} {%- endif %}"))
(text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}")
(div
("style" "display: contents")
(text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}"))
(text "{%- endmacro %}")
(text "{% macro post_info(post, community) -%}")
; info about the post: edited, date, etc.
(text "{% if post.context.edited != 0 -%}")
@ -200,8 +202,7 @@
(text "{{ icon \"trash-2\" }}"))
(text "{%- endif %}")
(text "{%- endmacro %}")
(text "{% macro post_buttons_box(post, community, owner, can_manage_post) -%}")
(text "{% macro post_buttons_box(post, community, owner, can_manage_post, show_comments=true) -%}")
(div
("class" "flex justify_between items_center gap_2 w_full")
(text "{% if user -%}")
@ -220,7 +221,7 @@
(text "{%- endif %}")
(div
("class" "flex gap_1 buttons_box")
(text "{% if post.context.comments_enabled %}")
(text "{% if show_comments and post.context.comments_enabled %}")
(a
("href" "/post/{{ post.id }}")
("class" "button camo small")
@ -358,8 +359,7 @@
("class" "flush flex gap_1 items_center")
(text "{{ self::community_avatar(id=post.community, community=community) }}")
(b
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))
(text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}")))
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))))
(text "{%- endif %} {%- endif %}")
(div
("class" "card flex flex_col post gap_2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}")
@ -384,9 +384,10 @@
(text "{{ self::avatar(username=owner.username, size=\"24px\", selector_type=\"username\") }}"))
(text "{%- endif %}")
(span
("class" "name")
; ("class" "name")
(text "{{ self::full_username(user=owner) }}"))
(text "{{ self::post_info(post=post, community=community) }}"))
(text "{{ self::post_info(post=post, community=community) }}")
(text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}"))
(text "{% if not dont_show_title and post.title and community and community.context.enable_titles -%}")
; post has a title AND whatever is rendering this component wants to see it
(a
@ -464,14 +465,15 @@
(text "{% if community and show_community and community.id != config.town_square or question %}"))
(text "{%- endif %} {%- endmacro %}")
(text "{% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
(text "{% macro post_media(upload_ids, custom_click=false) -%} {% if upload_ids|length > 0 -%}")
(div
("class" "media_gallery gap_2")
(text "{% for upload in upload_ids %}")
(img
("src" "/api/v1/uploads/{{ upload }}")
("data-upload-id" "{{ upload }}")
("alt" "Image upload")
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])"))
("onclick" "{% if custom_click -%} {{ custom_click }} {%- else -%} trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}']) {%- endif %}"))
(text "{% endfor %}"))
(text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}")
(div
@ -526,7 +528,7 @@
(text "{{ text \"general:action.delete\" }}"))))))
(text "{%- endmacro %}")
(text "{% macro forum_post(post, owner, community, can_manage_post, poll=false) -%}")
(text "{% macro forum_post(post, owner, community, can_manage_post, poll=false, show_show_thread=true) -%}")
(div
("class" "card_nest_horizontal_wrapper post post:{{ post.id }}")
("data-community" "{{ post.community }}")
@ -574,12 +576,38 @@
(div
("class" "flex flex_col gap_2")
("style" "flex: 1 0 auto")
(b ("class" "no_p_margin") (text "{{ post.title|markdown|safe }}"))
(span ("class" "no_p_margin") (text "{{ post.content|markdown|safe }}"))
(text "{{ self::post_media(upload_ids=post.uploads) }}")
(text "{% if poll -%} {{ self::poll(post=post, poll=poll) }} {%- endif %}"))
(hr ("class" "margin_top"))
(text "{{ self::post_buttons_box(post=post, community=community, owner=owner, can_manage_post=can_manage_post) }}"))))
(text "{{ self::post_buttons_box(post=post, community=community, owner=owner, can_manage_post=can_manage_post, show_comments=false) }}"))))
; show thread
(text "{% if show_show_thread and post.comment_count > 0 -%}")
(div
("class" "flex gap_2")
(text "{% if post.context.comments_enabled %}")
(a
("href" "/post/{{ post.id }}")
("class" "button lowered")
(icon (text "message-circle"))
(span
(text "{{ post.comment_count }}")))
(text "{% endif %}")
(button
("class" "lowered")
("onclick" "globalThis.continue_thread(event.target, '{{ post.id }}', 'replies_{{ post.id }}', 0)")
(icon (text "chevron-down"))
(str (text "general:action.show_thread"))))
(text "{%- endif %}")
; replies
(div
("class" "flex flex_col gap_2 hidden thread")
("id" "replies_{{ post.id }}"))
(text "{%- endmacro %}")
(text "{% macro user_card(user) -%}")
@ -596,9 +624,8 @@
(div
("class" "flex items_center")
(b
(text "{{ self::username(user=user) }}"))
(text "{{ self::full_username(user=user) }}"))
(text "{{ self::online_indicator(user=user) }}"))))
(text "{%- endmacro %} {% macro pagination(page=0, items=0, key=\"\", value=\"\") -%}")
(div
("class" "flex justify_between gap_2 w_full")
@ -867,7 +894,7 @@
(div
("class" "flex gap_2")
(button
(text "{{ text \"communities:action.create\" }}"))
(str (text "communities:action.create")))
(text "{% if drawing_enabled -%}")
(button
@ -1323,15 +1350,15 @@
(text "Listening to "))
(text "{{ other_user.connections.Spotify[1].data.artist }}")))
(text "{%- endif %} {%- endmacro %} {% macro user_plate(user, show_menu=false, show_kick=false, secondary=false) -%}")
(text "{%- endif %} {%- endmacro %} {% macro user_plate(user, show_menu=false, show_kick=false, secondary=false, full=false) -%}")
(div
("class" "flex gap_2 items_center card tiny user_plate {% if secondary -%}secondary{%- endif %}")
("class" "flex gap_2 items_center card tiny user_plate {% if secondary -%}secondary{%- endif %} {% if full -%} w_full {%- endif %}")
(a
("href" "/@{{ user.username }}")
(text "{{ self::avatar(username=user.username, size=\"42px\", selector_type=\"username\") }}"))
(div
("class" "flex justify_center flex_col")
("style" "{% if show_menu or show_kick -%}width: 60%{% else %}max-width: 150px{%- endif %}")
("style" "{% if show_menu or show_kick -%}width: 60%{% else %}max-width: calc(100% - 42px - var(--pad-4)){%- endif %}")
(text "{{ self::full_username(user=user) }}")
(div
("class" "user_status")
@ -1842,14 +1869,14 @@
; option a
(button
("class" "hover_left_bar raised justify-start w_full poll_option")
("class" "hover_left_bar raised justify_start w_full poll_option")
("onclick" "trigger('me::vote', ['{{ post.id }}', 'A'])")
(icon (text "tally-1"))
(text "{{ poll[0].option_a }}"))
; option b
(button
("class" "hover_left_bar raised justify-start w_full poll_option")
("class" "hover_left_bar raised justify_start w_full poll_option")
("onclick" "trigger('me::vote', ['{{ post.id }}', 'B'])")
(icon (text "tally-2"))
(text "{{ poll[0].option_b }}"))
@ -1857,7 +1884,7 @@
; option c
(text "{% if poll[0].option_c -%}")
(button
("class" "hover_left_bar raised justify-start w_full poll_option")
("class" "hover_left_bar raised justify_start w_full poll_option")
("onclick" "trigger('me::vote', ['{{ post.id }}', 'C'])")
(icon (text "tally-3"))
(text "{{ poll[0].option_c }}"))
@ -1866,7 +1893,7 @@
; option d
(text "{% if poll[0].option_d -%}")
(button
("class" "hover_left_bar raised justify-start w_full poll_option")
("class" "hover_left_bar raised justify_start w_full poll_option")
("onclick" "trigger('me::vote', ['{{ post.id }}', 'D'])")
(icon (text "tally-4"))
(text "{{ poll[0].option_d }}"))
@ -2160,7 +2187,7 @@
("class" "flex flex_row gap_1")
(a
("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}")
("class" "button justify-start lowered w_full")
("class" "button justify_start lowered w_full")
(icon (text "notebook"))
(text "{{ journal.title }}"))
@ -2186,7 +2213,7 @@
(div
("class" "flex flex_row gap_1")
(button
("class" "justify-start lowered w_full")
("class" "justify_start lowered w_full")
(icon (text "arrow-down"))
(text "{{ journal.title }}"))
@ -2236,7 +2263,7 @@
; create note
(text "{% if user and user.id == journal.owner -%}")
(button
("class" "lowered justify-start w_full")
("class" "lowered justify_start w_full")
("onclick" "create_note()")
(icon (text "plus"))
(str (text "journals:action.create_note")))
@ -2250,7 +2277,7 @@
(text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}")
(details
(summary
("class" "button w_full justify-start raised w_full")
("class" "button w_full justify_start raised w_full")
(icon (text "folder"))
(text "{{ dir[2] }}"))
@ -2278,7 +2305,7 @@
("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}")
(a
("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}")
("class" "button justify-start w_full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
("class" "button justify_start w_full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
(icon (text "file-text"))
(text "{{ note.title }}"))
@ -2359,7 +2386,7 @@
(div
("class" "flex flex_row gap_1")
(button
("class" "justify-start lowered w_full")
("class" "justify_start lowered w_full")
(icon (text "folder-open"))
(text "{{ dir[2] }}"))
@ -2402,7 +2429,7 @@
(text "{% macro note_mover_dirs_listing(dir, dirs) -%}")
(button
("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()")
("class" "justify-start lowered w_full")
("class" "justify_start lowered w_full")
(icon (text "folder-open"))
(text "{{ dir[2] }}"))
@ -2464,6 +2491,13 @@
(text "Create infinite Littleweb sites"))
(li
(text "Create infinite Littleweb domains"))
(li
(text "Create and sell CSS snippet products"))
(text "{% if config.enable_user_ads -%}")
(li
(text "No ads"))
(text "{%- endif %}")
(text "{% if config.security.enable_invite_codes -%}")
(li
@ -2636,8 +2670,18 @@
(text "{%- endif %}")
(text "{%- endmacro %}")
(text "{% macro topic_post_display(post, owner, is_pinned=false) -%}")
(text "{% macro topic_post_display(post, owner, is_pinned=false, community=false) -%}")
(tr
(text "{% if community %}")
(td
(a
("href" "/community/{{ community.title }}/topic/{{ post.topic }}")
("class" "flex gap_1 items_center w_content")
(text "{{ self::community_avatar(id=post.community, community=community) }}")
(span
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))))
(text "{%- endif %}")
(td
("class" "flex gap_1")
(a
@ -2648,5 +2692,55 @@
(text "{{ self::full_username(user=owner) }}"))
(td (text "{{ post.comment_count }}"))
(td (text "{{ ((post.likes + 1) / (post.dislikes + 1))|round(method=\"ceil\", precision=2) }}"))
(td (span ("class" "date") (text "{{ post.created }}"))))
(td (span ("class" "date short") (text "{{ post.created }}"))))
(text "{%- endmacro %}")
(text "{% macro product_listing_card(product, owner=false, edit=false) -%}")
(a
("class" "card button lowered w_full flex flex_col gap_2")
("href" "/product/{{ product.id }}{% if edit -%} /edit {%- endif %}")
(text "{% if owner -%}")
(text "{{ self::full_username(user=owner) }}")
(text "{%- endif %}")
(h3
("class" "flex gap_2 items_center {% if not product.on_sale -%} fade {%- endif %}")
("style" "height: 24px; text-decoration: {% if not product.on_sale -%} line-through {%- else -%} none {%- endif %}")
(icon (text "package"))
(text "{{ product.title }}"))
(h4
("class" "flex gap_2 items_center")
("style" "height: 18px")
(icon (text "badge-cent"))
(text "{{ product.price }}")))
(text "{%- endmacro %}")
(text "{% macro ad_listing_card(ad) -%}")
(a
("class" "card button lowered w_full flex flex_col gap_2")
("href" "/product/ad/{{ ad.id }}/edit")
(b
("class" "flex gap_2 items_center")
("style" "height: 24px; text-decoration: {% if not ad.is_running -%} line-through {%- else -%} none {%- endif %}")
(icon (text "link"))
(text "{{ ad.target }}"))
(b
("style" "height: 18px")
(text "{% if ad.is_running -%}")
(span
("class" "green flex gap_2 items_center")
(icon (text "circle-check"))
(text "Running"))
(text "{% else %}")
(span
("class" "red flex gap_2 items_center")
(icon (text "circle-x"))
(text "Not running"))
(text "{%- endif %}")))
(text "{%- endmacro %}")
(text "{% macro advertisement(size=\"Leaderboard\") -%}")
(text "{% if not is_supporter and config.enable_user_ads -%}")
(object ("class" "tetratto_ad") ("data-ad-size" "{{ size }}"))
(text "{%- endif %}")
(text "{%- endmacro %}")

View file

@ -54,7 +54,7 @@
("placeholder" "redirect URL")
("minlength" "2")))
(button
(text "{{ text \"communities:action.create\" }}"))))
(str (text "communities:action.create")))))
; app listing
(div

View file

@ -0,0 +1,63 @@
(text "<!doctype html>")
(html
("lang" "en")
(head
(meta ("charset" "UTF-8"))
(meta ("name" "viewport") ("content" "width=device-width, initial-scale=1.0"))
(meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge")))
(body
(a
("href" "{% if not disable_click -%} {{ config.host }}/api/v1/ads/host/{{ host }}/{{ ad.id }}/click {%- endif %}")
("title" "Advertisement")
("target" "_blank")
("class" "ad"))
(span ("class" "display_tag") (text "Ad"))
(style
(text "* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
line-height: 1.5;
letter-spacing: 0.15px;
font-family:
\"Inter\", \"Poppins\", \"Roboto\", ui-sans-serif, system-ui, sans-serif,
\"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\",
\"Noto Color Emoji\";
}
body {
overflow: hidden;
display: grid;
place-items: center;
}
a.ad {
display: inline;
width: 100dvw;
height: 100dvh;
background-image: url(\"{{ config.host|safe }}/api/v1/uploads/{{ ad.upload_id }}\");
background-position: center;
background-size: contain;
}
.display_tag {
position: absolute;
top: 0.5rem;
left: 0.5rem;
padding: 0.15rem 0.5rem;
background: hsla(0, 0%, 0%, 50%);
color: white;
font-weight: 600;
font-size: 10px;
user-select: none;
pointer-events: none;
border-radius: 0.4rem;
box-shadow: 0 0 2px hsla(0, 0%, 0%, 25%);
opacity: 25%;
}"))))

View file

@ -0,0 +1,568 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Manage product - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
(main
("class" "flex flex_col gap_2")
(div
("class" "card_nest")
(div
("class" "card small flex items_center gap_2")
(icon (text "images"))
(b
(str (text "economy:label.thumbnails"))))
(div
("class" "card flex flex_col gap_2")
(text "{{ components::post_media(upload_ids=product.uploads.thumbnails, custom_click=\"remove_thumbnail(event.target)\") }}")
(text "{% if product.uploads.thumbnails|length < 4 -%}")
(button
("onclick" "add_thumbnail()")
(icon (text "plus"))
(str (text "communities:label.upload")))
(text "{%- endif %}")))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "pencil-line"))
(b
(str (text "economy:label.title"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_title_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(str (text "economy:label.title")))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "title")
("required" "")
("minlength" "2")
("maxlength" "128")
("value" "{{ product.title }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "pencil-line"))
(b
(str (text "economy:label.description"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_description_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
("for" "description")
(str (text "economy:label.description")))
(textarea
("name" "description")
("id" "description")
("placeholder" "description")
("required" "")
("minlength" "2")
("maxlength" "1024")
(text "{{ product.description }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "badge-cent"))
(b
(str (text "economy:label.price"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_price_from_form(event)")
(label
("for" "on_sale")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "on_sale")
("name" "on_sale")
("class" "w_content")
("checked" "{{ product.on_sale }}")
("oninput" "event.preventDefault(); update_on_sale_from_form(event.target.checked)"))
(span
(str (text "economy:label.on_sale"))))
(label
("for" "single_use")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "single_use")
("name" "single_use")
("class" "w_content")
("checked" "{{ product.single_use }}")
("oninput" "event.preventDefault(); update_single_use_from_form(event.target.checked)"))
(span
(str (text "economy:label.single_use"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "price")
(str (text "economy:label.price")))
(input
("type" "number")
("name" "price")
("id" "price")
("placeholder" "price")
("required" "")
("min" "0")
("max" "1000000")
("value" "{{ product.price }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "weight"))
(b
(str (text "economy:label.stock"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_stock_from_form(event)")
(label
("for" "unlimited")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "unlimited")
("name" "unlimited")
("class" "w_content")
("checked" "{{ product.stock == -1 }}")
("oninput" "event.preventDefault(); event.target.checked ? document.getElementById('stock').value = -1 : document.getElementById('stock').value = 0"))
(span
(str (text "economy:label.unlimited"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "stock")
(str (text "economy:label.stock")))
(input
("type" "number")
("name" "stock")
("id" "stock")
("placeholder" "stock")
("required" "")
("min" "-1")
("max" "1000000")
("value" "{{ product.stock }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "package-check"))
(b
(str (text "economy:label.fulfillment_style"))))
(div
("class" "card flex flex_col gap_2")
(select
("id" "fulfillment_style_select")
("onchange" "mirror_fulfillment_style_select(true)")
(option ("value" "mail") (text "Mail") ("selected" "{{ not product.method == \"ProfileStyle\" }}"))
(option ("value" "snippet") (text "CSS Snippet") ("selected" "{{ product.method == \"ProfileStyle\" }}")))
(form
("class" "flex flex_col gap_2 hidden")
("id" "mail_fulfillment")
("onsubmit" "update_method_from_form(event)")
(p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below."))
(p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized."))
(text "{% set is_automail = product.method != \"ManualMail\" and product.method != \"ProfileStyle\" %}")
(label
("for" "use_automail")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "use_automail")
("name" "use_automail")
("class" "w_content")
("oninput" "mirror_use_automail()")
("checked" "{% if is_automail -%} true {%- else -%} false {%- endif %}"))
(span
(str (text "economy:label.use_automail"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "automail_message")
(str (text "economy:label.automail_message")))
(textarea
("name" "automail_message")
("id" "automail_message")
("placeholder" "automail_message")
(text "{% if is_automail -%} {{ product.method.AutoMail }} {%- endif %}")))
(button (str (text "general:action.save"))))
(form
("class" "flex flex_col gap_2 hidden")
("id" "snippet_fulfillment")
("onsubmit" "update_data_from_form(event)")
(text "{{ components::supporter_ad(body=\"Become a supporter to create snippets!\") }}")
(div
("class" "flex flex_col gap_1")
(label
("for" "data")
(str (text "economy:label.snippet_data")))
(textarea
("name" "data")
("id" "data")
("placeholder" "data")
(text "{{ product.data }}")))
(button (str (text "general:action.save"))))))
(div
("class" "flex gap_2")
(a
("class" "button secondary")
("href" "/product/{{ product.id }}")
(icon (text "arrow-left"))
(str (text "general:action.back")))
(button
("class" "lowered red")
("onclick" "delete_product()")
(icon (text "trash"))
(str (text "general:action.delete")))))
(script
(text "async function add_thumbnail() {
await trigger(\"atto::debounce\", [\"products::update\"]);
const picker = document.createElement(\"input\");
picker.type = \"file\";
picker.accept = \"image/*\";
document.body.appendChild(picker);
picker.click();
picker.addEventListener(\"change\", () => {
// create body
const body = new FormData();
for (const file of picker.files) {
body.append(file.name, file);
}
body.append(
\"body\",
JSON.stringify({
target: \"Thumbnails\"
}),
);
// ...
picker.remove();
fetch(\"/api/v1/products/{{ product.id }}/uploads\", {
method: \"POST\",
body,
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.reload();
}, 100);
}
});
});
}
async function remove_thumbnail(target) {
await trigger(\"atto::debounce\", [\"products::update\"]);
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/products/{{ product.id }}/uploads/thumbnails\", {
method: \"DELETE\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
idx: Array.from(target.parentElement.children).findIndex((x) => x.getAttribute(\"data-upload-id\") === target.getAttribute(\"data-upload-id\")),
}),
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.reload();
}, 100);
}
});
}
async function update_title_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/title\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_description_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/description\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
description: e.target.description.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_on_sale_from_form(on_sale) {
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/on_sale\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
on_sale,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_single_use_from_form(single_use) {
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/single_use\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
single_use,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_price_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/price\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
price: e.target.price.valueAsNumber,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_stock_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/stock\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
stock: e.target.stock.valueAsNumber,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_method_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/method\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
method: e.target.use_automail.checked ? { AutoMail: e.target.automail_message.value } : \"ManualMail\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_data_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/data\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
data: e.target.data.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function delete_product() {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/products/{{ product.id }}\", {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.mirror_use_automail = () => {
const use_automail = document.getElementById(\"use_automail\").checked;
if (use_automail) {
document.getElementById(\"automail_message\").removeAttribute(\"disabled\");
} else {
document.getElementById(\"automail_message\").setAttribute(\"disabled\", \"true\");
}
}
globalThis.mirror_fulfillment_style_select = (send = false) => {
const selected = document.getElementById(\"fulfillment_style_select\").selectedOptions[0].value;
if (selected === \"mail\") {
document.getElementById(\"mail_fulfillment\").classList.remove(\"hidden\");
document.getElementById(\"snippet_fulfillment\").classList.add(\"hidden\");
if (send) {
update_method_from_form({
preventDefault: () => {},
target: document.getElementById(\"mail_fulfillment\"),
});
}
} else {
document.getElementById(\"mail_fulfillment\").classList.add(\"hidden\");
document.getElementById(\"snippet_fulfillment\").classList.remove(\"hidden\");
if (send) {
fetch(\"/api/v1/products/{{ product.id }}/method\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
method: \"ProfileStyle\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
}
}
setTimeout(() => {
mirror_use_automail();
mirror_fulfillment_style_select();
}, 150);"))
(text "{% endblock %}")

View file

@ -0,0 +1,97 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Manage advertisement - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
(main
("class" "flex flex_col gap_2")
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "link"))
(b
(text "{{ ad.target }}")))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "event.preventDefault(); update_is_running_from_form(event.target.is_running.checked)")
(object ("class" "tetratto_ad") ("data-ad-size" "{{ ad.size }}") ("data-noclick" "true") ("data-ad-id" "{{ ad.id }}"))
(ul
(li
(text "{% if ad.last_charge_time != 0 -%}")
(text "Last charge: ") (span ("class" "date") (text "{{ ad.last_charge_time }}"))
(text "{% else %}")
(text "No previous charges")
(text "{%- endif %}")))
(div ("class" "squig"))
(p (text "Each day your ad is viewed, you'll be charged 25 coins. This charge only applies to the very first view of the day."))
(p (text "Additionally, you'll be charged 2 coins per click on your ad. This fee will be paid to the user which hosts the site your ad was shown on."))
(p (text "Each of these transfers will be shown in your wallet's transfer table as either \"AdClick\" or \"AdCharge\"."))
(label
("for" "is_running")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "is_running")
("name" "is_running")
("class" "w_content")
("checked" "{{ ad.is_running }}"))
(span
(str (text "economy:label.running"))))
(button (str (text "general:action.save")))))
(div
("class" "flex gap_2")
(a
("class" "button secondary")
("href" "/products")
(icon (text "arrow-left"))
(str (text "general:action.back")))
(button
("class" "lowered red")
("onclick" "delete_ad()")
(icon (text "trash"))
(str (text "general:action.delete")))))
(script
(text "async function update_is_running_from_form(is_running) {
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/ads/{{ ad.id }}/running\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
is_running,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function delete_ad() {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/ads/{{ ad.id }}\", {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};"))
(text "{% endblock %}")

View file

@ -0,0 +1,146 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ product.title }} - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
(main
("class" "flex flex_col gap_2")
(text "{{ components::post_media(upload_ids=product.uploads.thumbnails) }}")
(div
("class" "card flex flex_col gap_2")
(h3
("style" "height: 32px")
(text "{{ product.title }}"))
(text "{{ components::full_username(user=owner) }}")
(text "{% if product.stock >= 0 -%}")
(span ("class" "red") (text "{{ product.stock }} remaining"))
(text "{%- endif %}")
(div
("class" "card lowered w_full no_p_margin")
(text "{{ product.description|markdown|safe }}"))
(text "{% if already_purchased -%}")
(span
("class" "green flex items_center gap_2")
(icon (text "circle-check"))
(str (text "economy:label.already_purchased")))
(text "{%- endif %}")
(div
("class" "flex gap_2 items_center")
(text "{% if user.id != product.owner -%}")
(text "{% if not already_purchased -%}")
; price
(a
("class" "button camo lowered")
("href" "/wallet")
("target" "_blank")
(icon (text "badge-cent"))
(text "{{ product.price }}"))
; buy button
(button
("onclick" "purchase()")
("disabled" "{{ product.stock == 0 }}")
(icon (text "piggy-bank"))
(str (text "economy:action.buy")))
(text "{% else %}")
; profile style snippets
(text "{% if product.method == \"ProfileStyle\" -%} {% if not product.id in applied_configurations_mapped -%}")
(button
("onclick" "apply()")
(icon (text "check"))
(str (text "economy:action.apply")))
(text "{% else %}")
(button
("onclick" "remove()")
(icon (text "x"))
(str (text "economy:action.unapply")))
(text "{%- endif %} {%- endif %}")
; ...
(text "{%- endif %}")
(text "{% else %}")
(a
("class" "button")
("href" "/product/{{ product.id }}/edit")
(icon (text "settings"))
(str (text "general:label.edit")))
(text "{%- endif %}"))))
(script
(text "async function purchase() {
await trigger(\"atto::debounce\", [\"products::buy\"]);
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? Your new balance will be {{ user.coins - product.price }} coins.\",
]))
) {
return;
}
fetch(\"/api/v1/products/{{ product.id }}/buy\", {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
async function apply() {
await trigger(\"atto::debounce\", [\"user::update\"]);
fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
\"type\": \"StyleSnippet\",
\"id\": \"{{ product.id }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
async function remove() {
await trigger(\"atto::debounce\", [\"user::update\"]);
fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", {
method: \"DELETE\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
\"id\": \"{{ product.id }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}"))
(text "{% endblock %}")

View file

@ -0,0 +1,227 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "My products - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"products\") }}")
(main
("class" "flex flex_col gap_2")
; create new
(text "{{ components::supporter_ad(body=\"Become a supporter to create unlimited products!\") }}")
(div
("class" "card_nest")
(div
("class" "card small")
(b
(str (text "economy:label.create_new"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "create_product_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(str (text "economy:label.title")))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "title")
("required" "")
("minlength" "2")
("maxlength" "128")))
(div
("class" "flex flex_col gap_1")
(label
("for" "description")
(str (text "economy:label.description")))
(textarea
("name" "description")
("id" "description")
("placeholder" "description")
("required" "")
("minlength" "2")
("maxlength" "1024")))
(button
(str (text "communities:action.create")))))
; product listing
(div
("class" "card_nest")
(div
("class" "card small flex items_center gap_2")
(icon (text "store"))
(str (text "economy:label.my_products")))
(div
("class" "card flex flex_col gap_2")
(text "{% for item in list %} {{ components::product_listing_card(product=item, edit=true) }} {% endfor %}")
; selective pagination
(text "{% if page_set_id == 0 -%}")
(text "{{ components::pagination(page=page, items=list|length) }}")
(text "{% else %}")
(text "{{ components::pagination(page=0, items=list|length) }}")
(text "{%- endif %}")))
(text "{% if config.enable_user_ads -%}")
(div ("class" "squig") ("style" "--background: var(--color-surface)"))
; create new ad
(div
("class" "card_nest")
(div
("class" "card small")
(b
(str (text "economy:label.create_new_ad"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "create_ad_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
("for" "target")
(str (text "economy:label.target")))
(input
("type" "url")
("name" "target")
("id" "target")
("placeholder" "target url")
("required" "")
("minlength" "2")
("maxlength" "128")))
(div
("class" "flex flex_col gap_1")
(label
("for" "file")
(str (text "economy:label.image")))
(input
("id" "file")
("name" "file")
("type" "file")
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("required" "")
("class" "w_content")))
(div
("class" "flex flex_col gap_1")
(label
("for" "size_base")
(str (text "economy:label.size_base")))
(select
("id" "size_base")
("name" "size_base")
(option ("value" "Leaderboard") (text "Leaderboard (720x90)"))
(option ("value" "Billboard") (text "Billboard (970x250)"))
(option ("value" "Skyscraper") (text "Skyscraper (160x600)"))
(option ("value" "MediumRectangle") (text "Medium rectangle (300x250)"))
(option ("value" "MobileLeaderboard") (text "Mobile leaderboard (320x50, mobile only)"))))
(button
(str (text "communities:action.create")))))
; ad listing
(div
("class" "card_nest")
(div
("class" "card small flex items_center gap_2")
(icon (text "images"))
(str (text "economy:label.my_ads")))
(div
("class" "card flex flex_col gap_2")
(text "{% for item in ads_list %} {{ components::ad_listing_card(ad=item) }} {% endfor %}")
; selective pagination
(text "{% if page_set_id == 1 -%}")
(text "{{ components::pagination(page=page, items=ads_list|length, key=\"&page_set_id=1\") }}")
(text "{% else %}")
(text "{{ components::pagination(page=0, items=ads_list|length, key=\"&page_set_id=1\") }}")
(text "{%- endif %}")))
(div
("class" "card_nest")
(div
("class" "card small flex items_center gap_2")
(icon (text "code"))
(str (text "economy:label.embed_ads_on_my_site")))
(div
("class" "card flex flex_col gap_2")
(p (text "You can embed the advertising network into your site to earn a (coin) commission from clicks."))
(p (text "Place the following into your site's HTML:"))
(pre (code (text "&lt;script src=\"{{ config.host }}\"/js/ads.js\"&gt;&lt;/script&gt;
&lt;script&gt;TetrattoAds.init&lpar;&rpar;; TetrattoAds.render_ads&lpar;\"{{ user.id }}\", \"{{ config.host }}\"&rpar;&lt;/script&gt;")))
(p (text "After you've done that, you can place your ads like so:"))
(pre (code (text "&lt;object class=\"tetratto_ad\" data-ad-size=\"$size$\"&gt;&lt;/object&gt;")))
(p
(text "In the above example, replace \"$size$\" with a size from ")
(a ("href" "https://tetratto.com/reference/tetratto/model/economy/enum.UserAdSize.html") (text "here"))
(text " keep in mind that the name of a size must be in title case. That means it's \"Leaderboard\", not \"leaderboard\"."))))
(text "{%- endif %}"))
(script
(text "async function create_product_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::create\"]);
fetch(\"/api/v1/products\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
description: e.target.description.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
e.target.reset();
setTimeout(() => {
window.location.href = `/product/${res.payload}/edit`;
}, 100);
}
});
}
async function create_ad_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::create\"]);
// create body
const body = new FormData();
for (const file of e.target.file.files) {
body.append(file.name, file);
}
body.append(
\"body\",
JSON.stringify({
target: e.target.target.value,
size: e.target.size_base.selectedOptions[0].value,
}),
);
// ...
fetch(\"/api/v1/ads\", {
method: \"POST\",
body,
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
e.target.reset();
window.location.reload();
}
});
}"))
(text "{% endblock %}")

View file

@ -0,0 +1,153 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Wallet - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"wallet\") }}")
(main
("class" "flex flex_col gap_2")
(div
("class" "card_nest")
(div
("class" "card small flex items_center justify_between gap_2")
(span
("class" "flex items_center gap_2")
(icon (text "piggy-bank"))
(span (str (text "general:link.wallet"))))
(button
("class" "lowered small square tiny big_icon")
("onclick" "document.getElementById('buy_dialog').showModal()")
("title" "Buy coins")
(icon (text "plus"))))
(div
("class" "card lowered flex flex_col gap_4")
(button
("class" "card button raised")
("onclick" "document.getElementById('buy_dialog').showModal()")
(b (text "Coin balance"))
(h3
("class" "flex gap_2 items_center")
("style" "height: 24px")
(icon (text "badge-cent"))
(text "{{ user.coins }}")))))
(div
("class" "card_nest")
(div
("class" "card small flex items_center justify_between gap_2")
(span
("class" "flex items_center gap_2")
(icon (text "clock"))
(span (str (text "economy:label.recent_transfers")))))
(div
("class" "card flex flex_col gap_4")
(div
("class" "w_full")
("style" "overflow: auto")
(table
("class" "w_full")
(thead
(th (text "Created"))
(th (text "Sender"))
(th (text "Receiver"))
(th (text "Amount"))
(th (text "Product"))
(th (text "Source"))
(th (text "Actions")))
(tbody
(text "{% for transfer in list -%}")
(tr
(td (span ("class" "date short") (text "{{ transfer[3].created }}")))
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[0], wrap=false) }}"))
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[1], wrap=false) }}"))
(td
("class" "flex items_center gap_1")
(text "{{ transfer[3].amount }}")
(text "{% if transfer[3].is_pending -%}")
(span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock")))
(text "{%- endif %}"))
(td
(text "{% if transfer[2] -%}")
(a
("href" "/product/{{ transfer[2].id }}")
(icon (text "external-link")))
(text "{%- endif %}"))
(td (text "{{ transfer[3].source }}"))
(td
(text "{% if user.id == transfer[1].id and transfer[3].source == '\"Sale\"' -%}")
; we're the receiver
(button
("class" "small tiny square raised camo big_icon")
("onclick" "issue_refund('{{ transfer[3].id }}')")
("title" "Issue refund")
(icon (text "undo")))
(text "{%- endif %}")))
(text "{%- endfor %}"))))
(text "{{ components::pagination(page=page, items=list|length) }}"))))
(dialog
("id" "buy_dialog")
(div
("class" "inner flex flex_col gap_2")
(p (text "All coin purchases are one-time and will not recur."))
(p (text "If you do not receive your coins within a minute of purchase, please contact support."))
(button
("class" "lowered w_full justify_start")
("onclick" "checkout('Coins100')")
(text "100 coins ({{ config.stripe.price_texts.coins_100 }})"))
(button
("class" "w_full justify_start")
("onclick" "checkout('Coins400')")
(text "400 coins ({{ config.stripe.price_texts.coins_400 }})"))
(hr ("class" "margin"))
(div
("class" "flex gap_2 justify_between")
(div null?)
(button
("class" "lowered red")
("type" "button")
("onclick", "document.getElementById('buy_dialog').close()")
(icon (text "x"))
(str (text "dialog:action.cancel"))))))
(script
(text "globalThis.checkout = (product) => {
document.getElementById('buy_dialog').close();
fetch(\"/api/v1/service_hooks/stripe/checkout\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
product,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]);
if (res.ok) {
window.location.href = res.payload;
}
});
}
globalThis.issue_refund = async (transfer) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/transfers/${transfer}/refund`, {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]);
});
}"))
(text "{% endblock %}")

View file

@ -30,7 +30,7 @@
("minlength" "2")
("maxlength" "32")))
(button
(text "{{ text \"communities:action.create\" }}"))))
(str (text "communities:action.create")))))
(text "{% else %}")
(text "{{ components::developer_pass_ad(body=\"Get a developer pass to create forges!\") }}")
(text "{%- endif %}")

View file

@ -121,7 +121,7 @@
(div
("class" "flex flex_col gap_2 w_full")
(button
("class" "lowered justify-start w_full")
("class" "lowered justify_start w_full")
("onclick" "create_journal()")
(icon (text "plus"))
(str (text "journals:action.create_journal")))
@ -207,7 +207,7 @@
(details
("class" "w_full")
(summary
("class" "button lowered w_full justify-start")
("class" "button lowered w_full justify_start")
(icon (text "settings"))
(str (text "general:action.manage")))
@ -261,7 +261,7 @@
(details
("class" "w_full")
(summary
("class" "button lowered w_full justify-start")
("class" "button lowered w_full justify_start")
(icon (text "folders"))
(str (text "journals:label.directories")))

View file

@ -59,7 +59,7 @@
(option ("value" "{{ tld }}") (text ".{{ tld|lower }}"))
(text "{%- endfor %}")))
(button
(text "{{ text \"communities:action.create\" }}"))
(str (text "communities:action.create")))
(details
(summary

View file

@ -45,7 +45,7 @@
("minlength" "2")
("maxlength" "32")))
(button
(text "{{ text \"communities:action.create\" }}"))))
(str (text "communities:action.create")))))
(text "{%- endif %}")
(div
("class" "card_nest w_full")

View file

@ -73,14 +73,22 @@
("class" "inner")
(a
("href" "/chats/0/0")
("title" "Chats")
(icon (text "message-circle"))
(str (text "communities:label.chats")))
(a
("href" "/mail")
("title" "Mail")
(icon (text "mail"))
(str (text "general:link.mail")))
(text "{% if config.stripe -%}")
(a
("href" "/wallet")
(icon (text "piggy-bank"))
(str (text "economy:label.my_wallet")))
(a
("href" "/products")
(icon (text "store"))
(str (text "economy:label.my_products")))
(text "{%- endif %}")
(a
("href" "/journals/0/0")
(icon (text "notebook"))
@ -165,7 +173,7 @@
(text "{%- endif %}")))
(text "{%- endmacro %}")
(text "{% macro timelines_nav(selected=\"\", posts=\"\", questions=\"\", secondary_selected=\"posts\") -%}")
(text "{% macro timelines_nav(selected=\"\", posts=\"\", questions=\"\", secondary_selected=\"posts\", forum_posts=\"\") -%}")
(div
("class" "mobile_nav mobile")
; primary nav
@ -184,7 +192,7 @@
(text "{% if posts and questions -%}")
; secondary nav
(text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}")
(text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected, forum_posts=forum_posts) }}")
(text "{%- endif %}"))
(div
@ -194,7 +202,7 @@
; secondary nav desktop only
(text "{% if posts and questions -%}")
(text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}")
(text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected, forum_posts=forum_posts) }}")
(text "{%- endif %}"))
(text "{%- endmacro %}")
@ -252,7 +260,7 @@
(text "{%- endif %}")))
(text "{%- endmacro %}")
(text "{% macro timelines_secondary_nav(posts=\"\", questions=\"\", selected=\"posts\") -%} {% if user -%}")
(text "{% macro timelines_secondary_nav(posts=\"\", questions=\"\", selected=\"posts\", forum_posts=\"\") -%} {% if user -%}")
(div
("class" "pillmenu w_full")
(a
@ -261,6 +269,14 @@
(icon (text "newspaper"))
(span (str (text "communities:label.posts"))))
(text "{% if forum_posts|length > 0 -%}")
(a
("href" "{{ forum_posts }}")
("class" "{% if selected == 'forum_posts' -%}active{%- endif %}")
(icon (text "list-tree"))
(span (str (text "communities:label.forum_posts"))))
(text "{%- endif %}")
(a
("href" "{{ questions }}")
("class" "{% if selected == 'questions' -%}active{%- endif %}")
@ -310,6 +326,13 @@
("class" "{% if selected == 'media' -%}active{%- endif %}")
(str (text "auth:label.media")))
(text "{% if user and profile.settings.enable_shop -%}")
(a
("href" "/@{{ profile.username }}/shop")
("class" "{% if selected == 'shop' -%}active{%- endif %}")
(str (text "auth:label.shop")))
(text "{%- endif %}")
(text "{% if is_self or is_helper -%}")
(a
("href" "/@{{ profile.username }}/outbox")
@ -368,17 +391,3 @@
(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

@ -112,6 +112,7 @@
subject: e.target.subject.value.trim(),
receivers: RECEIVERS,
replying_to: SEARCH_PARAMS.get(\"replying_to\") || \"0\",
transfer_id: SEARCH_PARAMS.get(\"transfer_id\") || \"0\",
}),
})
.then((res) => res.json())

View file

@ -1,79 +0,0 @@
(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

@ -92,6 +92,37 @@
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}"))))))
(text "{% elif request.action_type == \"Transfer\" %}")
(div
("class" "card_nest")
(div
("class" "card small flex items_center gap_2")
(icon (text "piggy-bank"))
(span
(str (text "requests:label.coin_transfer_request"))))
(div
("class" "card flex flex_col gap_2")
(span (a ("href" "/api/v1/auth/user/find/{{ request.linked_asset }}") (text "Somebody")) (text " is asking for a transfer of ") (b (text "{{ request.data.Int32 }} coins")) (text "."))
(div
("class" "card flex flex_wrap w_full secondary gap_2")
(a
("href" "/api/v1/auth/user/find/{{ request.linked_asset }}")
("class" "button")
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"requests:action.view_profile\" }}")))
(button
("class" "lowered green")
("onclick" "accept_transfer_request(event, '{{ request.id }}', '{{ request.linked_asset }}', {{ request.data.Int32 }})")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.accept\" }}")))
(button
("class" "lowered red")
("onclick" "remove_request('{{ request.id }}', '{{ request.linked_asset }}')")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}"))))))
(text "{%- endif %} {% endfor %} {% for question in questions %}")
(div
("class" "card_nest")
@ -138,13 +169,15 @@
(text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}"))
(script
(text "async function remove_request(id, linked_asset) {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\",
]))
) {
return;
(text "async function remove_request(id, linked_asset, confirm = true) {
if (confirm) {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\",
]))
) {
return;
}
}
fetch(`/api/v1/requests/${id}/${linked_asset}`, {
@ -275,6 +308,41 @@
}
}
});
};
globalThis.accept_transfer_request = async (e, id, receiver, amount) => {
await trigger(\"atto::debounce\", [\"economy::transfer\"]);
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/transfers`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
receiver,
amount,
}),
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
e.target.parentElement.parentElement.parentElement.parentElement.remove();
remove_request(id, receiver, false);
}
});
};"))
(text "{% endblock %}")

View file

@ -28,7 +28,7 @@
("required" "")
("minlength" "16")))
(button
(text "{{ text \"communities:action.create\" }}")))))
(str (text "communities:action.create"))))))
(script
(text "function create_report_from_form(e) {

View file

@ -19,7 +19,7 @@
("class" "lowered small")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"communities:action.create\" }}"))))
(str (text "communities:action.create")))))
(div
("class" "card flex flex_col gap_2")
(text "{% for item in items %}")

View file

@ -84,7 +84,7 @@
const ui = await ns(\"ui\");
const element = document.getElementById(\"mod_options\");
globalThis.profile_request = async (do_confirm, path, body) => {
globalThis.profile_request = async (do_confirm, path, body = null, headers = { \"Content-Type\": \"application/json\" }, method = \"POST\") => {
if (do_confirm) {
if (
!(await trigger(\"atto::confirm\", [
@ -96,11 +96,9 @@
}
fetch(`/api/v1/auth/user/{{ profile.id }}/${path}`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify(body),
method,
headers: headers != null ? headers : undefined,
body: body != null ? JSON.stringify(body) : undefined,
})
.then((res) => res.json())
.then((res) => {
@ -265,9 +263,15 @@
(span
(text "{{ text \"mod_panel:label.associations\" }}"))))
(div
("class" "card lowered flex flex_wrap gap_2")
("class" "card flex flex_wrap gap_4 flex_collapse")
(text "{% for user in associations -%}")
(text "{{ components::user_plate(user=user, show_menu=false) }}")
(div
("class" "flex flex_row gap_2 items_center card small secondary")
(text "{{ components::user_plate(user=user, show_menu=false, secondary=true, full=true) }}")
(button
("class" "small square red lowered")
("onclick" "profile_request(true, 'associations/{{ user.id }}', null, null, 'DELETE')")
(icon (text "x"))))
(text "{%- endfor %}")))
(text "{% if invite -%}")
(div
@ -298,7 +302,7 @@
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
("for" "reason")
(str (text "mod_panel:label.ban_reason")))
(textarea
("type" "text")
@ -309,6 +313,37 @@
(text "{{ profile.ban_reason|remove_script_tags|safe }}")))
(button
(str (text "general:action.save")))))
(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_expiration")))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "event.preventDefault(); profile_request(false, 'ban_expire', { expire: new Date(event.target.expire.value).getTime() || 0 })")
(div
("class" "flex flex_col gap_1")
(label
("for" "expire")
(str (text "mod_panel:label.ban_expiration")))
(input
("type" "datetime-local")
("name" "expire")
("id" "expire")
("value" "{{ profile.ban_expire }}")))
(div
("class" "flex gap_2")
(button
(str (text "general:action.save")))
(button
("type" "button")
("class" "lowered red")
("onclick" "profile_request(false, 'ban_expire', { expire: 0 })")
(str (text "notifs:action.clear"))))))
(div
("class" "card_nest w_full")
(div

View file

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

View file

@ -0,0 +1,24 @@
(text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}")
(div
("class" "flex flex_col gap_2")
("id" "replies_{{ post.id }}_{{ page }}")
; replies
(text "{% for post in replies -%}")
(div
("style" "display: contents")
(text "{{ components::forum_post(post=post[0], owner=post[1], community=community, can_manage_post=can_manage_posts, poll=post[4]) }}"))
(text "{%- endfor %}")
; load more button
(text "{% set len = replies|length %}")
(text "{% if len != 0 and (page * 12) + len != post.comment_count -%}")
(div
(button
("class" "lowered")
("onclick" "globalThis.continue_thread(event.target, '{{ post.id }}', 'replies_{{ post.id }}', {{ page + 1 }})")
(icon (text "chevron-down"))
(str (text "general:action.load_more"))))
(text "{% else %}")
(div ("class" "squig"))
(text "{%- endif %}"))

View file

@ -56,7 +56,7 @@
(text "{% else %}")
(div
("style" "display: contents")
(text "{{ components::forum_post(post=post, owner=owner, community=community, can_manage_post=can_manage_posts, poll=poll) }}"))
(text "{{ components::forum_post(post=post, owner=owner, community=community, can_manage_post=can_manage_posts, poll=poll, show_show_thread=false) }}"))
(text "{%- endif %}")
; ...
(text "{% if user and post.context.comments_enabled -%}")
@ -88,7 +88,7 @@
("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
(text "{{ text \"communities:action.create\" }}")))))
(str (text "communities:action.create"))))))
(text "{%- endif %}")
(div
("class" "pillmenu")
@ -332,7 +332,24 @@
(text "{%- endif %} {% endfor %} {{ components::pagination(page=page, items=replies|length) }}"))))
(script
(text "async function create_reply_from_form(e) {
(text "globalThis.continue_thread = async (target, post_id, id, page = 0) => {
const btn_id = `tmp_${window.crypto.randomUUID()}`;
target.setAttribute(\"disabled\", \"true\");
target.id = btn_id;
document.getElementById(id).innerHTML +=
await (await fetch(`/post/${post_id}/_quick_replies?page=${page}`)).text();
document.getElementById(id).classList.remove(\"hidden\");
await trigger(\"atto::clean_date_codes\");
await trigger(\"atto::link_filter\");
await trigger(\"atto::hooks::check_reactions\");
await trigger(\"atto::hooks::online_indicator\");
document.getElementById(btn_id).parentElement.remove();
}
async function create_reply_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"posts::create\"]);

View file

@ -64,7 +64,7 @@
("id" "username")
("class" "username flex items_center gap_2 flex_wrap w_full")
(span
("class" "name shorter")
("class" "name")
(text "{{ components::username(user=profile) }}"))
(text "{% if profile.is_verified -%}")
(span
@ -84,6 +84,12 @@
("style" "color: var(--color-primary);")
("class" "flex items_center")
(text "{{ icon \"id-card-lanyard\" }}"))
(text "{%- endif %} {% if profile.checkouts|length > 0 -%}")
(span
("title" "Donator")
("style" "color: var(--color-primary);")
("class" "flex items_center")
(text "{{ icon \"hand-heart\" }}"))
(text "{%- endif %} {% if profile.permissions|has_staff_badge -%}")
(span
("title" "Staff")
@ -170,16 +176,6 @@
(text "Posts"))
(span
(text "{{ profile.post_count }}")))
(text "{% if gpa and gpa > 0 and (not user.settings.disable_gpa_fun or is_helper) -%}")
(div
("class" "w_full flex justify_between items_center")
("title" "great post average (limited time fun)")
(span
("class" "notification chip")
(text "GPA"))
(span
(text "{{ gpa|round(method=\"floor\", precision=2) }}")))
(text "{%- endif %}")
(text "{% if not profile.settings.private_last_seen or is_self or is_helper %}")
(div
("class" "w_full flex justify_between items_center")
@ -266,6 +262,13 @@
(icon (text "mail-plus"))
(span
(str (text "mail:action.send_mail"))))
(text "{%- endif %} {% if not profile.settings.no_transfers -%}")
(button
("onclick" "request_transfer()")
("class" "lowered")
(icon (text "badge-cent"))
(span
(str (text "economy:action.request"))))
(text "{%- endif %} {% if is_helper -%}")
(a
("href" "/mod_panel/profile/{{ profile.id }}")
@ -299,6 +302,41 @@
});
};
globalThis.request_transfer = async () => {
await trigger(\"atto::debounce\", [\"economy::transfer\"]);
const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"0\");
if (amount === 0) {
return;
}
if (
!(await trigger(\"atto::confirm\", [
`Are you sure you would like to request ${amount} coins from {{ profile.username }}?`,
]))
) {
return;
}
fetch(`/api/v1/transfers/ask`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
receiver: \"{{ profile.id }}\",
amount,
}),
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.toggle_follow_user = async (e) => {
await trigger(\"atto::debounce\", [
\"users::follow\",
@ -430,6 +468,12 @@
("class" "rhs w_full flex flex_col gap_4")
(text "{% block content %}{% endblock %}")))))
(text "{% if not use_user_theme -%}")
(text "{% for cnf in applied_configurations -%}")
(text "{{ cnf|safe }}")
(text "{%- endfor %}")
(text "{%- endif %}")
(text "{% if not is_self and profile.settings.warning -%}")
(script
(text "setTimeout(() => {

View file

@ -3,19 +3,8 @@
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(text "{{ icon \"pin\" }}")
(span
(text "{{ text \"communities:label.pinned\" }}")))
(div
("class" "card flex flex_col gap_4")
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")))
(text "{%- endif %} {{ macros::profile_nav(selected=\"posts\") }}")
(text "{%- endif %}")
(text "{{ macros::profile_nav(selected=\"posts\") }}")
(div
("class" "card_nest")
(div
@ -42,6 +31,12 @@
(div
("class" "card w_full flex flex_col gap_2")
("ui_ident" "io_data_load")
; pinned
(text "{% if pinned and pinned|length > 0 -%}")
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")
(div ("class" "squig"))
(text "{%- endif %}")
; ...
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")

View file

@ -3,19 +3,8 @@
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(text "{{ icon \"pin\" }}")
(span
(text "{{ text \"communities:label.pinned\" }}")))
(div
("class" "card flex flex_col gap_4")
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")))
(text "{%- endif %} {{ macros::profile_nav(selected=\"responses\") }}")
(text "{%- endif %}")
(text "{{ macros::profile_nav(selected=\"responses\") }}")
(div
("class" "card_nest")
(div
@ -42,6 +31,12 @@
(div
("class" "card w_full flex flex_col gap_2")
("ui_ident" "io_data_load")
; pinned
(text "{% if pinned and pinned|length > 0 -%}")
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")
(div ("class" "squig"))
(text "{%- endif %}")
; ...
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")

View file

@ -1162,6 +1162,26 @@
("class" "fade")
(text "This represents the site theme shown to users viewing
your profile.")))))
(text "{% if profile.applied_configurations|length > 0 -%}")
(div
("class" "card_nest")
("ui_ident" "applied_configurations")
(div
("class" "card small flex items_center gap_2")
(icon (text "cog"))
(str (text "setttings:label.applied_configurations")))
(div
("class" "card")
(p (text "Products that you have purchased and applied to your profile are displayed below. Snippets are always synced to the product, meaning the owner could update it at any time."))
(ul
(text "{% for cnf in profile.applied_configurations -%}")
(li
(text "{{ cnf[0] }} ")
(a
("href" "/product/{{ cnf[1] }}")
(text "{{ cnf[1] }}")))
(text "{%- endfor %}"))))
(text "{%- endif %}")
(button
("onclick" "save_settings()")
("id" "save_button")
@ -1742,6 +1762,7 @@
\"import_export\",
\"theme_preference\",
\"profile_theme\",
\"applied_configurations\",
]);
ui.generate_settings_ui(
@ -1906,6 +1927,14 @@
\"{{ profile.settings.hide_social_follows }}\",
\"checkbox\",
],
[
[
\"hide_username_badges\",
\"Hide badges from your username (outside of your profile)\",
],
\"{{ profile.settings.hide_username_badges }}\",
\"checkbox\",
],
[[], \"Questions\", \"title\"],
[
[
@ -1954,10 +1983,27 @@
\"textarea\",
],
[
[\"forum_signature\", \"Forum signature (coming soon)\"],
[\"forum_signature\", \"Forum signature\"],
settings.forum_signature,
\"textarea\",
],
[[], \"Economy\", \"title\"],
[
[
\"enable_shop\",
\"Show shop tab on my profile\",
],
\"{{ profile.settings.enable_shop }}\",
\"checkbox\",
],
[
[
\"no_transfers\",
\"Disable transfer requests\",
],
\"{{ profile.settings.no_transfers }}\",
\"checkbox\",
],
[[], \"Misc\", \"title\"],
[
[\"hide_dislikes\", \"Hide post dislikes\"],
@ -1970,11 +2016,6 @@
\"text\",
],
[[], \"Fun\", \"title\"],
[
[\"disable_gpa_fun\", \"Disable GPA\"],
\"{{ profile.settings.disable_gpa_fun }}\",
\"checkbox\",
],
[
[\"disable_achievements\", \"Disable achievements\"],
\"{{ profile.settings.disable_achievements }}\",

View file

@ -0,0 +1,17 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
(text "{%- endif %}")
(text "{{ macros::profile_nav(selected=\"shop\") }}")
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "store"))
(str (text "auth:label.shop")))
(div
("class" "card w_full flex flex_col gap_2")
(text "{% for item in list %} {{ components::product_listing_card(product=item) }} {% endfor %}")
(text "{{ components::pagination(page=page, items=list|length) }}")))
(text "{% endblock %}")

View file

@ -76,7 +76,17 @@
(span ("class" "fade") (text "The following reason was provided by a moderator:"))
(div
("class" "card lowered w_full")
(text "{{ user.ban_reason|markdown|safe }}"))))))
(text "{{ user.ban_reason|markdown|safe }}"))
(text "{% if user.ban_expire != 0 -%}")
(hr)
(span
(text "Your ban will expire on: ")
(span ("id" "ban_expire")))
(script
(text "document.getElementById(\"ban_expire\").innerText = new Date({{ user.ban_expire }}).toLocaleString();"))
(text "{% else %}")
(span (text "This ban is marked as permanent."))
(text "{%- endif %}")))))
; if we aren't banned, just show the page body
(text "{% elif user and user.awaiting_purchase %}")
; account waiting for payment message

View file

@ -16,7 +16,7 @@
(span (text "Select a stack to add this user to:"))
(text "{% for stack in stacks %}")
(button
("class" "justify-start lowered w_full")
("class" "justify_start lowered w_full")
("onclick" "choose_stack('{{ stack.id }}')")
(icon (text "layers"))
(text "{{ stack.name }}"))

View file

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

View file

@ -1,11 +1,12 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Latest posts - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex_col gap_2")
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\") }} {% if not user -%}")
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", forum_posts=\"/all/forum_posts\") }}")
(text "{{ components::advertisement(size=\"Leaderboard\") }}")
(text "{% if not user -%}")
(div
("class" "card_nest")
(div
@ -32,11 +33,9 @@
("class" "card w_full flex flex_col gap_2")
("ui_ident" "io_data_load")
(div ("ui_ident" "io_data_marker"))))
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(() => {
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&before=$1$&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
});"))
(text "{% endblock %}")

View file

@ -0,0 +1,34 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Latest forum posts - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex_col gap_2")
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"forum_posts\", forum_posts=\"/all/forum_posts\") }}")
(div
("class" "card w_full flex flex_col gap_2")
(text "{% if config.town_square_forum != 0 -%}")
(a
("href" "/communities/intents/post?community={{ config.town_square_forum }}&topic={{ config.town_square_forum_topic }}&sig=true&topics=true")
("class" "button small lowered")
("data-turbo" "false")
(icon (text "plus"))
(span
(str (text "general:action.post"))))
(text "{%- endif %}")
(div
("class" "w_full")
("style" "overflow: auto")
(table
("class" "w_full")
(thead
(th (text "In"))
(th (text "Title"))
(th (text "Replies"))
(th (text "Score"))
(th (text "Created")))
(tbody
(text "{% for post in feed %} {{ components::topic_post_display(post=post[0], owner=post[1], community=post[2]) }} {% endfor %}"))))
(text "{{ components::pagination(page=page, items=feed|length) }}")))
(text "{% endblock %}")

View file

@ -1,13 +1,11 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Latest questions - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex_col gap_2")
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"questions\") }}")
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"questions\", forum_posts=\"/all/forum_posts\") }}")
(div
("class" "card w_full flex flex_col gap_2")
(text "{% for question in list %} {{ components::global_question(question=question, can_manage_questions=false, secondary=true) }} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))
(text "{% endblock %}")

View file

@ -0,0 +1,65 @@
globalThis.TetrattoAds = {
AD_SIZES: {
Billboard: [970, 250],
Leaderboard: [720, 90],
Skyscraper: [160, 600],
MediumRectangle: [300, 250],
MobileLeaderboard: [320, 50],
},
IS_MOBILE: window.innerWidth <= 900 && window.innerHeight <= 900,
};
globalThis.TetrattoAds.init = () => {
const styles = document.createElement("style");
styles.id = "tetratto_ads_css";
styles.setAttribute("data-turbo-permanent", "true");
styles.innerHTML = `.tetratto_ad {
width: 100%;
display: grid;
place-items: center;
}
.tetratto_ad,
.tetratto_ad iframe {
max-width: 100%;
background: transparent;
}`;
document.head.appendChild(styles);
};
globalThis.TetrattoAds.render_ads = (
host_id = 0,
tetratto = "https://tetratto.com",
) => {
for (const element of Array.from(
document.querySelectorAll(".tetratto_ad"),
)) {
if (element.children.length > 0) {
continue;
}
const iframe = document.createElement("iframe");
let size = element.getAttribute("data-ad-size") || "MediumRectangle";
if (size === "Leaderboard" && TetrattoAds.IS_MOBILE) {
size = "MobileLeaderboard";
}
const size_px = TetrattoAds.AD_SIZES[size];
const noclick =
element.getAttribute("data-noclick") === "true" || false;
const ad_id = element.getAttribute("data-ad-id");
iframe.src = `${tetratto}/adn/${ad_id ? ad_id : "random"}?size=${size}&host=${host_id}&noclick=${noclick}`;
iframe.setAttribute("frameborder", "0");
iframe.loading = "lazy";
iframe.style.width = `${size_px[0]}px`;
iframe.style.height = `${size_px[1]}px`;
element.appendChild(iframe);
}
};

View file

@ -85,6 +85,10 @@ media_theme_pref();
element.removeAttribute("checked");
}
for (const element of document.querySelectorAll('[disabled="false"]')) {
element.removeAttribute("disabled");
}
for (const element of document.querySelectorAll('[selected="true"]')) {
element.parentElement.value = element.value;
}

View file

@ -726,7 +726,7 @@
element.innerHTML = "";
for (const token of Object.entries($.LOGIN_ACCOUNT_TOKENS)) {
element.innerHTML += `<div class="flex gap_2 flex_row">
<button class="lowered w_full justify-start" onclick="trigger('me::login', ['${token[0]}'])">
<button class="lowered w_full justify_start" onclick="trigger('me::login', ['${token[0]}'])">
<img
title="${token[0]}'s avatar"
src="/api/v1/auth/user/${token[0]}/avatar?selector_type=username"
@ -1205,60 +1205,3 @@
]);
});
})();
(() => {
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

@ -0,0 +1,146 @@
use crate::{
cookie::CookieJar,
get_user_from_token,
image::{save_webp_buffer, JsonMultipart},
State,
};
use axum::{
extract::Path,
response::{IntoResponse, Redirect},
Extension, Json,
};
use tetratto_core::model::{
economy::UserAd,
oauth,
uploads::{MediaType, MediaUpload},
ApiReturn, Error,
};
use super::{CreateAd, UpdateAdIsRunning};
const MAXIMUM_AD_FILE_SIZE: usize = 2_097_152;
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
JsonMultipart(bytes_parts, req): JsonMultipart<CreateAd>,
) -> 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()),
};
// get file
let file = match bytes_parts.get(0) {
Some(x) => x,
None => return Json(Error::Unknown.into()),
};
if file.len() > MAXIMUM_AD_FILE_SIZE {
return Json(Error::FileTooLarge.into());
}
let upload = match data
.create_upload(MediaUpload::new(MediaType::Webp, user.id))
.await
{
Ok(x) => x,
Err(e) => return Json(e.into()),
};
match data
.create_ad(UserAd::new(user.id, upload.id, req.target, req.size))
.await
{
Ok(_) => {
// write image
if let Err(e) =
save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
{
return Json(Error::MiscError(e.to_string()).into());
}
// ...
Json(ApiReturn {
ok: true,
message: "Ad created".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_ad(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Ad deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_is_running_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAdIsRunning>,
) -> 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()),
};
let ad = match data.get_ad_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if !ad.is_running && user.coins < 50 {
return Json(
Error::MiscError(
"You must have a minimum of 50 coins in your balance to run ads".to_string(),
)
.into(),
);
}
match data
.update_ad_is_running(id, &user, if req.is_running { 1 } else { 0 })
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Ad updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn click_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((host, id)): Path<(usize, usize)>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = get_user_from_token!(jar, data);
match data.ad_click(host, id, user).await {
Ok(t) => Redirect::to(&t),
Err(_) => Redirect::to(&data.0.0.host),
}
}

View file

@ -1,15 +1,20 @@
use std::{str::FromStr, time::Duration};
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json};
use crate::cookie::CookieJar;
use axum::{
extract::Query,
http::HeaderMap,
response::{IntoResponse, Redirect},
Extension, Json,
};
use tetratto_core::model::{
auth::{Notification, User},
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource},
moderation::AuditLogEntry,
permissions::{FinePermission, SecondaryPermission},
ApiReturn, Error,
};
use stripe::{EventObject, EventType};
use crate::{get_user_from_token, State};
use crate::{get_user_from_token, State, cookie::CookieJar};
use serde::Deserialize;
pub async fn stripe_webhook(
Extension(data): Extension<State>,
@ -133,7 +138,7 @@ pub async fn stripe_webhook(
if let Err(e) = data
.create_audit_log_entry(AuditLogEntry::new(
0,
format!("invoice tier update failed: stripe {customer_id}"),
format!("invoice user update failed: stripe {customer_id}"),
))
.await
{
@ -179,7 +184,7 @@ pub async fn stripe_webhook(
let new_user_permissions = user.permissions | FinePermission::SUPPORTER;
if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.update_user_role(user.id, new_user_permissions, &user, true)
.await
{
return Json(e.into());
@ -225,7 +230,7 @@ pub async fn stripe_webhook(
user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS;
if let Err(e) = data
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
.update_user_secondary_role(user.id, new_user_permissions, &user, true)
.await
{
return Json(e.into());
@ -284,7 +289,7 @@ pub async fn stripe_webhook(
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.update_user_role(user.id, new_user_permissions, &user, true)
.await
{
return Json(e.into());
@ -310,7 +315,7 @@ pub async fn stripe_webhook(
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
if let Err(e) = data
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
.update_user_secondary_role(user.id, new_user_permissions, &user, true)
.await
{
return Json(e.into());
@ -396,7 +401,7 @@ pub async fn stripe_webhook(
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.update_user_role(user.id, new_user_permissions, &user, true)
.await
{
return Json(e.into());
@ -437,7 +442,7 @@ pub async fn stripe_webhook(
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
if let Err(e) = data
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
.update_user_secondary_role(user.id, new_user_permissions, &user, true)
.await
{
return Json(e.into());
@ -472,9 +477,21 @@ pub async fn stripe_webhook(
})
}
pub async fn onboarding_account_link_request(
#[derive(Deserialize)]
pub enum ProductIDAlias {
Coins100,
Coins400,
}
#[derive(Deserialize)]
pub struct CreateCheckoutSessionProps {
pub product: ProductIDAlias,
}
pub async fn create_stupid_fucking_checkout_session(
jar: CookieJar,
Extension(data): Extension<State>,
Json(props): Json<CreateCheckoutSessionProps>,
) -> impl IntoResponse {
let data = &(data.read().await);
let user = match get_user_from_token!(jar, data.0) {
@ -482,134 +499,181 @@ pub async fn onboarding_account_link_request(
None => return Json(Error::NotAllowed.into()),
};
if user.seller_data.account_id.is_some() {
return Json(Error::NotAllowed.into());
}
let client = match data.3 {
let stripe_cnf = match data.0.0.0.stripe {
Some(ref c) => c,
None => return Json(Error::Unknown.into()),
None => return Json(Error::MiscError("Disabled".to_string()).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()),
let stripe_client = match data.3 {
Some(ref x) => x,
None => return Json(Error::MiscError("Disabled".to_string()).into()),
};
let session = match stripe::CheckoutSession::create(
&stripe_client,
stripe::CreateCheckoutSession {
customer_creation: if user.stripe_id.is_empty() {
Some(stripe::CheckoutSessionCustomerCreation::Always)
} else {
None
},
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),
customer: if user.stripe_id.is_empty() {
None
} else {
Some(stripe::CustomerId::from_str(&user.stripe_id).unwrap())
},
line_items: Some(vec![stripe::CreateCheckoutSessionLineItems {
quantity: Some(1),
adjustable_quantity: Some(
stripe::CreateCheckoutSessionLineItemsAdjustableQuantity {
enabled: false,
..Default::default()
},
),
price: Some(match props.product {
ProductIDAlias::Coins100 => stripe_cnf.price_ids.coins_100.clone(),
ProductIDAlias::Coins400 => stripe_cnf.price_ids.coins_400.clone(),
}),
..Default::default()
}),
}]),
client_reference_id: Some(&user.id.to_string()),
mode: Some(stripe::CheckoutSessionMode::Payment),
ui_mode: Some(stripe::CheckoutSessionUiMode::Hosted),
success_url: Some(&format!(
"{}/api/v1/service_hooks/stripe/checkout/success?session_id={{CHECKOUT_SESSION_ID}}",
data.0.0.0.host
)),
..Default::default()
},
)
.await
{
Ok(a) => a,
Ok(x) => x,
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()),
}
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: session.url.unwrap(),
})
}
pub async fn login_link_request(
#[derive(Deserialize)]
pub struct CheckoutSessionSuccessProps {
pub session_id: String,
}
/// By this point, we can assume the customer has properly paid.
///
/// This endpoint will just read the purchase, apply the purchase, and then redirect home.
pub async fn handle_stupid_fucking_checkout_success_session(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
Query(props): Query<CheckoutSessionSuccessProps>,
) -> std::result::Result<impl IntoResponse, Json<ApiReturn<()>>> {
let data = &(data.read().await);
let user = match get_user_from_token!(jar, data.0) {
let mut user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
None => return Err(Json(Error::NotAllowed.into())),
};
if user.seller_data.account_id.is_none() | !user.seller_data.completed_onboarding {
return Json(Error::NotAllowed.into());
if user.checkouts.contains(&props.session_id) {
return Err(Json(
Error::MiscError("You can only do this once".to_string()).into(),
));
}
let client = match data.3 {
let stripe_cnf = match data.0.0.0.stripe {
Some(ref c) => c,
None => return Json(Error::Unknown.into()),
None => return Err(Json(Error::MiscError("Disabled".to_string()).into())),
};
match stripe::LoginLink::create(
&client,
&stripe::AccountId::from_str(&user.seller_data.account_id.unwrap()).unwrap(),
&data.0.0.0.host,
let stripe_client = match data.3 {
Some(ref x) => x,
None => return Err(Json(Error::MiscError("Disabled".to_string()).into())),
};
let session = match stripe::CheckoutSession::retrieve(
&stripe_client,
&match stripe::CheckoutSessionId::from_str(&props.session_id) {
Ok(x) => x,
Err(_) => {
return Err(Json(
Error::MiscError("Invalid session ID".to_string()).into(),
));
}
},
&[&"line_items"],
)
.await
{
Ok(x) => Json(ApiReturn {
ok: true,
message: "Acceptable".to_string(),
payload: Some(x.url),
}),
Err(e) => Json(Error::MiscError(e.to_string()).into()),
Ok(x) => x,
Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())),
};
let price_id = session
.line_items
.as_ref()
.unwrap()
.data
.get(0)
.as_ref()
.unwrap()
.price
.as_ref()
.unwrap()
.id
.to_string();
if price_id == stripe_cnf.price_ids.coins_100 {
if let Err(e) = data
.0
.create_transfer(
&mut CoinTransfer::new(
data.0.0.0.system_user,
user.id,
100,
CoinTransferMethod::Transfer,
CoinTransferSource::Purchase,
),
true,
)
.await
{
return Err(Json(e.into()));
}
} else if price_id == stripe_cnf.price_ids.coins_400 {
if let Err(e) = data
.0
.create_transfer(
&mut CoinTransfer::new(
data.0.0.0.system_user,
user.id,
400,
CoinTransferMethod::Transfer,
CoinTransferSource::Purchase,
),
true,
)
.await
{
return Err(Json(e.into()));
}
} else {
tracing::error!(
"received an invalid stripe price id, please check config.stripe.price_ids"
);
return Err(Json(
Error::MiscError("Unknown price ID".to_string()).into(),
));
}
user.checkouts.push(props.session_id);
if let Err(e) = data.0.update_user_checkouts(user.id, user.checkouts).await {
return Err(Json(e.into()));
}
Ok(Redirect::to("/wallet"))
}

View file

@ -1,12 +1,13 @@
use std::{str::FromStr, time::Duration};
use std::time::Duration;
use crate::{
get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::{
AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason,
UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword,
UpdateUserRole, UpdateUserUsername,
AddAppliedConfiguration, AppendAssociations, AwardAchievement, DeleteUser, DisableTotp,
RefreshGrantToken, RemoveAppliedConfiguration, UpdateSecondaryUserRole,
UpdateUserAwaitingPurchase, UpdateUserBanExpire, UpdateUserBanReason, UpdateUserInviteCode,
UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole,
UpdateUserUsername,
},
State,
};
@ -24,6 +25,7 @@ use tetratto_core::{
cache::Cache,
model::{
auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS},
economy::CoinTransferMethod,
moderation::AuditLogEntry,
oauth,
permissions::FinePermission,
@ -180,8 +182,108 @@ pub async fn update_user_settings_request(
}
}
/// Add the given applied configuration.
pub async fn add_applied_configuration_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<AddAppliedConfiguration>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
let product_id: usize = match req.id.parse() {
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
let product = match data.get_product_by_id(product_id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if data
.get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id))
.await
.is_err()
{
return Json(Error::NotAllowed.into());
}
// update
user.applied_configurations.push((req.r#type, product.id));
// ...
match data
.update_user_applied_configurations(id, user.applied_configurations)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Applied configurations updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Remove the given applied configuration.
pub async fn remove_applied_configuration_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<RemoveAppliedConfiguration>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
let product_id: usize = match req.id.parse() {
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
// update
user.applied_configurations.remove(
match user
.applied_configurations
.iter()
.position(|x| x.1 == product_id)
{
Some(x) => x,
None => return Json(Error::GeneralNotFound("configuration".to_string()).into()),
},
);
// ...
match data
.update_user_applied_configurations(id, user.applied_configurations)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Applied configurations updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Append associations to the current user.
pub async fn append_associations_request(
pub async fn append_association_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(req): Json<AppendAssociations>,
@ -229,6 +331,50 @@ pub async fn append_associations_request(
}
}
/// Remove an association from the given user.
pub async fn remove_association_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((uid, association)): Path<(usize, usize)>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
// get user
let mut other_user = match data.get_user_by_id(uid).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
// find association and remove
other_user.associated.remove(
match other_user.associated.iter().position(|x| x == &association) {
Some(x) => x,
None => return Json(Error::GeneralNotFound("association".to_string()).into()),
},
);
// ...
match data
.update_user_associated(other_user.id, other_user.associated)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Associations updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Update the password of the given user.
///
/// Does not support third-party grants.
@ -423,7 +569,7 @@ pub async fn update_user_role_request(
None => return Json(Error::NotAllowed.into()),
};
match data.update_user_role(id, req.role, user, false).await {
match data.update_user_role(id, req.role, &user, false).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "User updated".to_string(),
@ -449,7 +595,7 @@ pub async fn update_user_secondary_role_request(
};
match data
.update_user_secondary_role(id, req.role, user, false)
.update_user_secondary_role(id, req.role, &user, false)
.await
{
Ok(_) => Json(ApiReturn {
@ -490,6 +636,35 @@ pub async fn update_user_ban_reason_request(
}
}
/// Update the ban expiration date of the given user.
///
/// Does not support third-party grants.
pub async fn update_user_ban_expire_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<UpdateUserBanExpire>,
) -> 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_expire(id, req.expire as i64).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;
@ -543,28 +718,11 @@ pub async fn delete_user_request(
.delete_user(id, &req.password, user.permissions.check_manager())
.await
{
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: (),
})
}
Ok(_) => Json(ApiReturn {
ok: true,
message: "User deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
@ -857,36 +1015,6 @@ pub async fn post_to_socket_request(
})
}
/// Calculate the user's great post average.
pub async fn get_user_gpa_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::UserReadProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
let gpa = data.calculate_user_gpa(id).await;
return Json(ApiReturn {
ok: true,
message: if gpa >= 3.0 {
"cool".to_string()
} else if gpa >= 4.0 {
"extraordinary".to_string()
} else {
"ok".to_string()
},
payload: Some(gpa),
});
}
/// Remove a grant token.
pub async fn remove_grant_request(
jar: CookieJar,

View file

@ -12,7 +12,6 @@ use tetratto_core::model::{
permissions::FinePermission,
ApiReturn, Error,
};
use crate::{
get_user_from_token,
routes::api::v1::{
@ -120,7 +119,7 @@ pub async fn update_context_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateCommunityContext>,
Json(mut req): Json<UpdateCommunityContext>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManage) {
@ -128,6 +127,16 @@ pub async fn update_context_request(
None => return Json(Error::NotAllowed.into()),
};
let community = match data.get_community_by_id_no_void(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if community.is_forge || community.is_forum {
req.context.enable_titles = true;
req.context.require_titles = true;
}
// check lengths
if req.context.display_name.len() > 32 {
return Json(Error::DataTooLong("display name".to_string()).into());
@ -255,6 +264,41 @@ pub async fn update_owner_request(
}
}
pub async fn update_is_forum_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::CommunityManage) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let mut community = match data.get_community_by_id_no_void(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
community.context.enable_titles = true;
community.context.require_titles = true;
match data.update_community_is_forum(id, &user, 1).await {
Ok(_) => match data
.update_community_context(id, &user, community.context)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Community updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
},
Err(e) => Json(e.into()),
}
}
pub async fn get_membership(
jar: CookieJar,
Extension(data): Extension<State>,
@ -552,7 +596,7 @@ pub async fn add_topic_request(
return Json(Error::DataTooLong("title".to_string()).into());
}
if req.title.len() < 2 {
if req.title.trim().len() < 2 {
return Json(Error::DataTooShort("title".to_string()).into());
}
@ -561,7 +605,13 @@ pub async fn add_topic_request(
}
// ...
let (topic_id, topic) = ForumTopic::new(req.title, req.description, req.color, req.position);
let (topic_id, topic) = ForumTopic::new(
req.title,
req.description,
req.color,
req.position,
community.write_access,
);
community.topics.insert(topic_id, topic);
match data
@ -603,7 +653,7 @@ pub async fn update_topic_request(
return Json(Error::DataTooLong("title".to_string()).into());
}
if req.title.len() < 2 {
if req.title.trim().len() < 2 {
return Json(Error::DataTooShort("title".to_string()).into());
}
@ -617,6 +667,7 @@ pub async fn update_topic_request(
description: req.description,
color: req.color,
position: req.position,
write_access: req.write_access,
};
community.topics.insert(topic_id, topic);

View file

@ -3,7 +3,7 @@ use axum::{
response::IntoResponse,
Extension, Json,
};
use tetratto_core::model::{auth::Notification, mail::Letter, oauth, ApiReturn, Error};
use tetratto_core::model::{mail::Letter, oauth, ApiReturn, Error};
use crate::{cookie::CookieJar, get_user_from_token, routes::pages::PaginatedQuery, State};
use super::CreateLetter;
@ -170,17 +170,13 @@ pub async fn create_request(
.await
{
Ok(l) => {
// send notifications
for x in &l.receivers {
// check if we're fulfilling a coin transfer
if !props.transfer_id.is_empty() && props.transfer_id != "0" {
if let Err(e) = data
.create_notification(Notification::new(
"You've got mail!".to_string(),
format!(
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/letter/{}).",
user.username, user.id, l.id
),
*x,
))
.apply_transfer(match props.transfer_id.parse() {
Ok(x) => x,
Err(_) => return Json(Error::Unknown.into()),
})
.await
{
return Json(e.into());

View file

@ -1,3 +1,4 @@
pub mod ads;
pub mod app_data;
pub mod apps;
pub mod auth;
@ -14,6 +15,7 @@ pub mod reports;
pub mod requests;
pub mod services;
pub mod stacks;
pub mod transfers;
pub mod uploads;
pub mod util;
@ -24,17 +26,17 @@ use axum::{
use serde::Deserialize;
use tetratto_core::model::{
apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota},
auth::AchievementName,
auth::{AchievementName, AppliedConfigType},
communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
PollOption, PostContext,
},
communities_permissions::CommunityPermission,
economy::{ProductFulfillmentMethod, UserAdSize},
journals::JournalPrivacyPermission,
littleweb::{DomainData, DomainTld, ServiceFsEntry},
oauth::AppScope,
permissions::{FinePermission, SecondaryPermission},
products::{ProductPrice, ProductType},
reactions::AssetType,
stacks::{StackMode, StackPrivacy, StackSort},
};
@ -95,6 +97,10 @@ pub fn routes() -> Router {
"/communities/{id}/context",
post(communities::communities::update_context_request),
)
.route(
"/communities/{id}/is_forum",
post(communities::communities::update_is_forum_request),
)
.route(
"/communities/{id}/access/read",
post(communities::communities::update_read_access_request),
@ -328,6 +334,14 @@ pub fn routes() -> Router {
"/auth/user/{id}/settings",
post(auth::profile::update_user_settings_request),
)
.route(
"/auth/user/{id}/applied_configuration",
post(auth::profile::add_applied_configuration_request),
)
.route(
"/auth/user/{id}/applied_configuration",
delete(auth::profile::remove_applied_configuration_request),
)
.route(
"/auth/user/{id}/role",
post(auth::profile::update_user_role_request),
@ -340,6 +354,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/ban_reason",
post(auth::profile::update_user_ban_reason_request),
)
.route(
"/auth/user/{id}/ban_expire",
post(auth::profile::update_user_ban_expire_request),
)
.route(
"/auth/user/{id}",
delete(auth::profile::delete_user_request),
@ -380,6 +398,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/totp/codes",
post(auth::profile::refresh_totp_codes_request),
)
.route(
"/auth/user/{id}/associations/{association}",
delete(auth::profile::remove_association_request),
)
.route(
"/auth/user/{username}/totp/check",
get(auth::profile::has_totp_enabled_request),
@ -387,7 +409,7 @@ pub fn routes() -> Router {
.route("/auth/user/me/seen", post(auth::profile::seen_request))
.route(
"/auth/user/me/append_associations",
put(auth::profile::append_associations_request),
put(auth::profile::append_association_request),
)
.route("/auth/user/find/{id}", get(auth::profile::redirect_from_id))
.route(
@ -399,10 +421,6 @@ pub fn routes() -> Router {
get(auth::profile::redirect_from_stripe_id),
)
.route("/auth/ip/{ip}/block", post(auth::social::ip_block_request))
.route(
"/auth/user/{id}/gpa",
get(auth::profile::get_user_gpa_request),
)
.route(
"/auth/user/{id}/_connect/{stream}",
any(auth::profile::subscription_handler),
@ -559,16 +577,12 @@ pub fn routes() -> Router {
post(auth::connections::stripe::stripe_webhook),
)
.route(
"/service_hooks/stripe/seller/register",
post(auth::connections::stripe::create_seller_account_request),
"/service_hooks/stripe/checkout",
post(auth::connections::stripe::create_stupid_fucking_checkout_session),
)
.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),
"/service_hooks/stripe/checkout/success",
get(auth::connections::stripe::handle_stupid_fucking_checkout_success_session),
)
// channels
.route("/channels", post(channels::channels::create_request))
@ -708,17 +722,6 @@ 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))
// letters
.route("/letters", post(letters::create_request))
.route("/letters/{id}", get(letters::get_request))
@ -726,6 +729,50 @@ pub fn routes() -> Router {
.route("/letters/{id}/read", post(letters::add_read_request))
.route("/letters/sent", get(letters::list_sent_request))
.route("/letters/received", get(letters::list_received_request))
// transfers
.route("/transfers", post(transfers::create_request))
.route("/transfers/ask", post(transfers::ask_request))
.route(
"/transfers/{id}/refund",
post(transfers::create_refund_request),
)
// products
.route("/products", post(products::create_request))
.route("/products/{id}", delete(products::delete_request))
.route("/products/{id}/buy", post(products::buy_request))
.route("/products/{id}/title", post(products::update_title_request))
.route(
"/products/{id}/description",
post(products::update_description_request),
)
.route("/products/{id}/data", post(products::update_data_request))
.route(
"/products/{id}/on_sale",
post(products::update_on_sale_request),
)
.route(
"/products/{id}/single_use",
post(products::update_single_use_request),
)
.route("/products/{id}/price", post(products::update_price_request))
.route(
"/products/{id}/method",
post(products::update_method_request),
)
.route("/products/{id}/stock", post(products::update_stock_request))
.route(
"/products/{id}/uploads",
post(products::update_uploads_request),
)
.route(
"/products/{id}/uploads/thumbnails",
delete(products::remove_thumbnail_request),
)
// ads
.route("/ads", post(ads::create_request))
.route("/ads/{id}", delete(ads::delete_request))
.route("/ads/{id}/running", post(ads::update_is_running_request))
.route("/ads/host/{host}/{id}/click", get(ads::click_request))
}
pub fn lw_routes() -> Router {
@ -797,6 +844,8 @@ pub struct AddTopic {
pub color: String,
#[serde(default)]
pub position: i32,
#[serde(default)]
pub write_access: CommunityWriteAccess,
}
#[derive(Deserialize)]
@ -914,6 +963,11 @@ pub struct UpdateUserBanReason {
pub reason: String,
}
#[derive(Deserialize)]
pub struct UpdateUserBanExpire {
pub expire: usize,
}
#[derive(Deserialize)]
pub struct UpdateUserInviteCode {
pub invite_code: String,
@ -1192,29 +1246,6 @@ 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,
@ -1248,4 +1279,96 @@ pub struct CreateLetter {
pub subject: String,
pub content: String,
pub replying_to: String,
#[serde(default)]
pub transfer_id: String,
}
#[derive(Deserialize)]
pub struct CreateCoinTransfer {
pub receiver: String,
pub amount: i32,
}
#[derive(Deserialize)]
pub struct CreateProduct {
pub title: String,
pub description: String,
}
#[derive(Deserialize)]
pub struct UpdateProductTitle {
pub title: String,
}
#[derive(Deserialize)]
pub struct UpdateProductDescription {
pub description: String,
}
#[derive(Deserialize)]
pub struct UpdateProductData {
pub data: String,
}
#[derive(Deserialize)]
pub struct UpdateProductOnSale {
pub on_sale: bool,
}
#[derive(Deserialize)]
pub struct UpdateProductSingleUse {
pub single_use: bool,
}
#[derive(Deserialize)]
pub struct UpdateProductPrice {
pub price: i32,
}
#[derive(Deserialize)]
pub struct UpdateProductMethod {
pub method: ProductFulfillmentMethod,
}
#[derive(Deserialize)]
pub struct UpdateProductStock {
pub stock: i32,
}
#[derive(Deserialize)]
pub struct AddAppliedConfiguration {
pub r#type: AppliedConfigType,
pub id: String,
}
#[derive(Deserialize)]
pub struct RemoveAppliedConfiguration {
pub id: String,
}
#[derive(Deserialize, PartialEq, Eq)]
pub enum ProductUploadTarget {
Thumbnails,
Reward,
}
#[derive(Deserialize)]
pub struct UpdateProductUploads {
pub target: ProductUploadTarget,
}
#[derive(Deserialize)]
pub struct RemoveProductThumbnail {
pub idx: usize,
}
#[derive(Deserialize)]
pub struct CreateAd {
pub target: String,
pub size: UserAdSize,
}
#[derive(Deserialize)]
pub struct UpdateAdIsRunning {
pub is_running: bool,
}

View file

@ -1,68 +1,27 @@
use crate::{
cookie::CookieJar,
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 axum::{extract::Path, response::IntoResponse, Extension, Json};
use tetratto_core::model::{
economy::{Product, ProductFulfillmentMethod},
oauth,
products::Product,
permissions::FinePermission,
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()),
}
}
use super::{
CreateProduct, ProductUploadTarget, RemoveProductThumbnail, UpdateProductData,
UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, UpdateProductPrice,
UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, UpdateProductUploads,
};
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
JsonMultipart(uploads, req): JsonMultipart<CreateProduct>,
Json(req): Json<CreateProduct>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) {
@ -70,143 +29,14 @@ pub async fn create_request(
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)
.create_product(Product::new(user.id, req.title, req.description))
.await
{
Ok(_) => Json(ApiReturn {
Ok(s) => 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: (),
message: "Product created".to_string(),
payload: s.id.to_string(),
}),
Err(e) => Json(e.into()),
}
@ -232,3 +62,422 @@ pub async fn delete_request(
Err(e) => Json(e.into()),
}
}
pub async fn update_title_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(mut req): Json<UpdateProductTitle>,
) -> 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()),
};
req.title = req.title.trim().to_string();
if req.title.len() < 2 {
return Json(Error::DataTooShort("title".to_string()).into());
} else if req.title.len() > 128 {
return Json(Error::DataTooLong("title".to_string()).into());
}
match data.update_product_title(id, &user, &req.title).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(mut 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()),
};
req.description = req.description.trim().to_string();
if req.description.len() < 2 {
return Json(Error::DataTooShort("description".to_string()).into());
} else if req.description.len() > 1024 {
return Json(Error::DataTooLong("description".to_string()).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_data_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(mut req): Json<UpdateProductData>,
) -> 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()),
};
req.data = req.data.trim().to_string();
if req.data.len() > 16384 {
return Json(Error::DataTooLong("data".to_string()).into());
}
match data.update_product_data(id, &user, &req.data).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_on_sale_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateProductOnSale>,
) -> 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_on_sale(id, &user, if req.on_sale { 1 } else { 0 })
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_single_use_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateProductSingleUse>,
) -> 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_single_use(id, &user, if req.single_use { 1 } else { 0 })
.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()),
};
let product = match data.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
let can_be_free = product.method != ProductFulfillmentMethod::ProfileStyle;
if req.price < 25 && (!can_be_free || req.price != 0) {
return Json(
Error::MiscError(
"Price is too low, please use a price of 25 coins or more".to_string(),
)
.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 update_method_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateProductMethod>,
) -> 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()),
};
if req.method == ProductFulfillmentMethod::ProfileStyle
&& !user.permissions.check(FinePermission::SUPPORTER)
{
return Json(Error::RequiresSupporter.into());
}
let product = match data.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if req.method == ProductFulfillmentMethod::ProfileStyle && product.price == 0 {
// no free profile styles
if let Err(e) = data.update_product_price(id, &user, 25).await {
return Json(e.into());
}
}
match data.update_product_method(id, &user, req.method).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_stock_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateProductStock>,
) -> 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_stock(id, &user, req.stock).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn buy_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::UserSendCoins) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.purchase_product(id, &mut user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product purchased".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
const MAXIMUM_THUMBNAIL_FILE_SIZE: usize = 2_097_152;
const MAXIMUM_REWARD_FILE_SIZE: usize = 4_194_304;
/// Update the product's uploads. Only reads one multipart file entry.
pub async fn update_uploads_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
JsonMultipart(bytes_parts, req): JsonMultipart<UpdateProductUploads>,
) -> 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()),
};
let mut product = match data.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
// apply to target
match req.target {
ProductUploadTarget::Thumbnails => {
if product.uploads.thumbnails.len() == 4 {
return Json(
Error::MiscError("Too many thumbnails exist. Please remove one".to_string())
.into(),
);
}
// create upload
let file = match bytes_parts.get(0) {
Some(x) => x,
None => return Json(Error::Unknown.into()),
};
if file.len() > MAXIMUM_THUMBNAIL_FILE_SIZE {
return Json(Error::FileTooLarge.into());
}
let upload = match data
.create_upload(MediaUpload::new(MediaType::Webp, user.id))
.await
{
Ok(x) => x,
Err(e) => return Json(e.into()),
};
product.uploads.thumbnails.push(upload.id);
// write image
if let Err(e) =
save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
{
return Json(Error::MiscError(e.to_string()).into());
}
}
ProductUploadTarget::Reward => {
// remove old
if product.uploads.reward != 0 {
if let Err(e) = data.delete_upload(product.uploads.reward).await {
return Json(e.into());
}
}
// create upload
let file = match bytes_parts.get(0) {
Some(x) => x,
None => return Json(Error::Unknown.into()),
};
if file.len() > MAXIMUM_REWARD_FILE_SIZE {
return Json(Error::FileTooLarge.into());
}
let upload = match data
.create_upload(MediaUpload::new(MediaType::Webp, user.id))
.await
{
Ok(x) => x,
Err(e) => return Json(e.into()),
};
product.uploads.reward = upload.id;
// write image
if let Err(e) =
save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
{
return Json(Error::MiscError(e.to_string()).into());
}
}
}
// ...
match data
.update_product_uploads(id, &user, product.uploads)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn remove_thumbnail_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<RemoveProductThumbnail>,
) -> 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()),
};
let mut product = match data.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
// remove upload
let thumbnail = match product.uploads.thumbnails.get(req.idx) {
Some(x) => x,
None => return Json(Error::GeneralNotFound("thumbnail".to_string()).into()),
};
if let Err(e) = data.delete_upload(*thumbnail).await {
return Json(e.into());
}
product.uploads.thumbnails.remove(req.idx);
// ...
match data
.update_product_uploads(id, &user, product.uploads)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -0,0 +1,120 @@
use crate::{get_user_from_token, State, cookie::CookieJar};
use axum::{response::IntoResponse, Extension, Json, extract::Path};
use tetratto_core::model::{
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource},
oauth,
requests::{ActionData, ActionRequest, ActionType},
ApiReturn, Error,
};
use super::CreateCoinTransfer;
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(req): Json<CreateCoinTransfer>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.create_transfer(
&mut CoinTransfer::new(
user.id,
match req.receiver.parse() {
Ok(x) => x,
Err(_) => return Json(Error::Unknown.into()),
},
req.amount,
CoinTransferMethod::Transfer, // this endpoint is ONLY for regular transfers; products export a buy endpoint for the other method
CoinTransferSource::General,
),
true,
)
.await
{
Ok(s) => Json(ApiReturn {
ok: true,
message: "Transfer created".to_string(),
payload: s.to_string(),
}),
Err(e) => Json(e.into()),
}
}
pub async fn ask_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(req): Json<CreateCoinTransfer>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.create_request(ActionRequest::new(
match req.receiver.parse() {
Ok(x) => x,
Err(_) => return Json(Error::Unknown.into()),
},
ActionType::Transfer,
user.id,
Some(ActionData::Int32(req.amount)),
))
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Asked user for transfer".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn create_refund_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::UserSendCoins) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let other_transfer = match data.get_transfer_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if user.id != other_transfer.receiver || other_transfer.source != CoinTransferSource::Sale {
// only the receiver of the funds can issue a refund (atm)
return Json(Error::NotAllowed.into());
}
match data
.create_transfer(
&mut CoinTransfer::new(
other_transfer.receiver,
other_transfer.sender,
other_transfer.amount,
CoinTransferMethod::Transfer,
CoinTransferSource::Refund,
),
true,
)
.await
{
Ok(s) => Json(ApiReturn {
ok: true,
message: "Transfer created".to_string(),
payload: s.to_string(),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -20,7 +20,7 @@ pub async fn get_request(
Body::from(read_image(PathBufD::current().extend(&[
data.0.0.dirs.media.as_str(),
"images",
"default-avatar.svg",
"default-banner.svg",
]))),
));
}
@ -34,7 +34,7 @@ pub async fn get_request(
Body::from(read_image(PathBufD::current().extend(&[
data.0.0.dirs.media.as_str(),
"images",
"default-avatar.svg",
"default-banner.svg",
]))),
));
}

View file

@ -21,3 +21,4 @@ serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
serve_asset!(carp_js_request: CARP_JS("text/javascript"));
serve_asset!(proto_links_request: PROTO_LINKS_JS("text/javascript"));
serve_asset!(app_sdk_request: APP_SDK_JS("text/javascript"));
serve_asset!(ads_request: ADS_JS("text/javascript"));

View file

@ -22,6 +22,7 @@ pub fn routes(config: &Config) -> Router {
.route("/js/carp.js", get(assets::carp_js_request))
.route("/js/proto_links.js", get(assets::proto_links_request))
.route("/js/app_sdk.js", get(assets::app_sdk_request))
.route("/js/ads.js", get(assets::ads_request))
.nest_service(
"/public",
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),

View file

@ -266,6 +266,12 @@ pub struct CreatePostProps {
pub from_draft: usize,
#[serde(default)]
pub quote: usize,
#[serde(default)]
pub topic: usize,
#[serde(default, alias = "sig")]
pub use_signature: bool,
#[serde(default, alias = "topics")]
pub show_topics: bool,
}
/// `/communities/intents/post`
@ -348,6 +354,20 @@ pub async fn create_post_request(
None
};
// fetch topics
let topics = if props.show_topics {
if props.community != 0 {
match data.0.get_community_by_id_no_void(props.community).await {
Ok(x) => x.topics,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
}
} else {
HashMap::new()
}
} else {
HashMap::new()
};
// ...
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
@ -359,6 +379,10 @@ pub async fn create_post_request(
context.insert("communities", &communities);
context.insert("selected_stack", &props.stack);
context.insert("selected_community", &props.community);
context.insert("selected_topic", &props.topic);
context.insert("use_signature", &props.use_signature);
context.insert("topics", &topics);
context.insert("show_topics", &props.show_topics);
// return
Ok(Html(
@ -987,8 +1011,6 @@ pub async fn post_request(
}
// ...
let ignore_users = crate::ignore_users_gen!(user, data);
let feed = match data
.0
.get_replies_by_post(
@ -1061,6 +1083,125 @@ pub async fn post_request(
Ok(Html(data.1.render("post/post.html", &context).unwrap()))
}
/// `/post/{id}/_quick_replies`
pub async fn forum_quick_replies_request(
jar: CookieJar,
Path(id): Path<usize>,
Query(props): Query<PaginatedQuery>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
let post = match data.0.get_post_by_id(id).await {
Ok(p) => p,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
if post.is_deleted {
// act like the post doesn't exist (if missing MANAGE_POSTS)
if let Some(ref ua) = user {
if !ua.permissions.check(FinePermission::MANAGE_POSTS) {
return Err(Html(
render_error(
Error::GeneralNotFound("post".to_string()),
&jar,
&data,
&user,
)
.await,
));
}
} else {
return Err(Html(
render_error(
Error::GeneralNotFound("post".to_string()),
&jar,
&data,
&user,
)
.await,
));
}
}
// ...
let community = match data.0.get_community_by_id(post.community).await {
Ok(c) => c,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
// check permissions
let (can_read, can_manage_pins) = check_community_permissions!(community, jar, data, user);
if !can_read {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &user).await,
));
}
// ...
let ignore_users = crate::ignore_users_gen!(user, data);
let feed = match data
.0
.get_replies_by_post(
post.id,
12,
props.page,
if community.is_forum { "ASC" } else { "DESC" },
)
.await
{
Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await {
Ok(p) => p,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
// init context
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;
let (
is_owner,
is_joined,
is_pending,
can_post,
can_manage_posts,
can_manage_community,
can_manage_roles,
can_manage_questions,
) = community_context_bools!(data, user, community);
context.insert("post", &post);
context.insert("replies", &feed);
context.insert("page", &props.page);
context.insert("can_manage_pins", &can_manage_pins);
community_context(
&mut context,
&community,
is_owner,
is_joined,
is_pending,
can_post,
can_read,
can_manage_posts,
can_manage_community,
can_manage_roles,
can_manage_questions,
);
// return
Ok(Html(
data.1
.render("post/forum_quick_replies.html", &context)
.unwrap(),
))
}
/// `/post/{id}/reposts`
pub async fn reposts_request(
jar: CookieJar,

View file

@ -0,0 +1,326 @@
use axum::{
extract::{Query, Path},
response::{Html, IntoResponse},
Extension,
};
use crate::cookie::CookieJar;
use tetratto_core::model::{
economy::{CoinTransferMethod, UserAd, UserAdSize},
Error,
};
use crate::{assets::initial_context, get_lang, get_user_from_token, State};
use super::{render_error, PaginatedQuery};
use serde::Deserialize;
/// `/wallet`
pub async fn wallet_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 list = match data.0.get_transfers_by_user(user.id, 12, props.page).await {
Ok(x) => match data.0.fill_transfers(x).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("list", &list);
context.insert("page", &props.page);
// return
Ok(Html(
data.1.render("economy/wallet.html", &context).unwrap(),
))
}
/// `/products`
pub async fn products_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 list = match data
.0
.get_products_by_user(
user.id,
12,
if props.page_set_id == 0 {
props.page
} else {
0
},
)
.await
{
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let ads_list = match data
.0
.get_ads_by_user(
user.id,
12,
if props.page_set_id == 1 {
props.page
} else {
0
},
)
.await
{
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("list", &list);
context.insert("ads_list", &ads_list);
context.insert("page", &props.page);
context.insert("page_set_id", &props.page_set_id);
// return
Ok(Html(
data.1.render("economy/products.html", &context).unwrap(),
))
}
/// `/product/{id}/edit`
pub async fn edit_product_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> 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 product = match data.0.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if user.id != product.owner {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("product", &product);
// return
Ok(Html(data.1.render("economy/edit.html", &context).unwrap()))
}
/// `/product/{id}`
pub async fn product_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> 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 product = match data.0.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let owner = match data.0.get_user_by_id(product.owner).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let already_purchased = if product.single_use {
data.0
.get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id))
.await
.is_ok()
} else {
false
};
let applied_configurations_mapped: Vec<usize> =
user.applied_configurations.iter().map(|x| x.1).collect();
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("product", &product);
context.insert("owner", &owner);
context.insert("already_purchased", &already_purchased);
context.insert(
"applied_configurations_mapped",
&applied_configurations_mapped,
);
// return
Ok(Html(
data.1.render("economy/product.html", &context).unwrap(),
))
}
/// `/product/ad/{id}/edit`
pub async fn edit_ad_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> 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 ad = match data.0.get_ad_by_id(id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if user.id != ad.owner {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("ad", &ad);
// return
Ok(Html(
data.1.render("economy/edit_ad.html", &context).unwrap(),
))
}
#[derive(Deserialize)]
pub struct RandomAdQuery {
pub host: usize,
#[serde(default)]
pub size: UserAdSize,
#[serde(default)]
pub noclick: bool,
}
/// `/adn/random`
pub async fn random_ad_request(
Extension(data): Extension<State>,
Query(props): Query<RandomAdQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let ad = match data.0.random_ad_charged(props.size.clone()).await {
Ok(x) => x,
Err(_) => UserAd {
id: 0,
created: 0,
upload_id: 0,
owner: data.0.0.0.system_user,
target: data.0.0.0.host.clone(),
last_charge_time: 0,
is_running: true,
size: props.size,
},
};
let mut context = tera::Context::new();
context.insert("disable_click", &props.noclick);
context.insert("config", &data.0.0.0);
context.insert("host", &props.host);
context.insert("ad", &ad);
// return
(
[(
"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' blob: *; frame-ancestors *",
)],
Html(data.1.render("economy/ad.html", &context).unwrap()),
)
}
/// `/adn/{id}`
pub async fn known_ad_request(
Extension(data): Extension<State>,
Query(props): Query<RandomAdQuery>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = data.read().await;
let ad = match data.0.get_ad_by_id(id).await {
Ok(x) => x,
Err(_) => UserAd {
// polyfill ad
id: 0,
created: 0,
upload_id: 0,
owner: data.0.0.0.system_user,
target: data.0.0.0.host.clone(),
last_charge_time: 0,
is_running: true,
size: props.size,
},
};
let mut context = tera::Context::new();
context.insert("disable_click", &props.noclick);
context.insert("config", &data.0.0.0);
context.insert("host", &props.host);
context.insert("ad", &ad);
// return
(
[
(
"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' blob: *; frame-ancestors *",
),
("Cache-Control", "no-cache"),
],
Html(data.1.render("economy/ad.html", &context).unwrap()),
)
}

View file

@ -1,107 +0,0 @@
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

@ -1,6 +1,7 @@
use super::{PaginatedQuery, render_error};
use crate::{
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State,
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token,
InnerState, State,
};
use axum::{
extract::{Path, Query},
@ -9,11 +10,14 @@ use axum::{
};
use crate::cookie::CookieJar;
use serde::Deserialize;
use tetratto_core::model::{
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS},
permissions::FinePermission,
requests::ActionType,
Error,
use tetratto_core::{
database::FullPost,
model::{
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS, User},
permissions::FinePermission,
requests::ActionType,
Error,
},
};
use std::fs::read_to_string;
use pathbufd::PathBufD;
@ -47,8 +51,8 @@ pub async fn index_request(
// i'm only changing this for stripe
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &None).await;
context.insert("page", &req.page);
Html(data.1.render("timelines/all.html", &context).unwrap())
};
}
@ -79,6 +83,7 @@ pub async fn index_request(
context.insert("list", &list);
context.insert("page", &req.page);
Html(data.1.render("timelines/home.html", &context).unwrap())
}
@ -93,8 +98,8 @@ pub async fn popular_request(
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;
context.insert("page", &req.page);
Html(data.1.render("timelines/popular.html", &context).unwrap())
}
@ -116,8 +121,8 @@ pub async fn following_request(
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("page", &req.page);
Ok(Html(
data.1.render("timelines/following.html", &context).unwrap(),
))
@ -134,8 +139,8 @@ pub async fn all_request(
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;
context.insert("page", &req.page);
Html(data.1.render("timelines/all.html", &context).unwrap())
}
@ -172,6 +177,7 @@ pub async fn index_questions_request(
context.insert("list", &list);
context.insert("page", &req.page);
Html(
data.1
.render("timelines/home_questions.html", &context)
@ -212,6 +218,7 @@ pub async fn popular_questions_request(
context.insert("list", &list);
context.insert("page", &req.page);
Html(
data.1
.render("timelines/popular_questions.html", &context)
@ -254,6 +261,7 @@ pub async fn following_questions_request(
context.insert("list", &list);
context.insert("page", &req.page);
Ok(Html(
data.1
.render("timelines/following_questions.html", &context)
@ -271,7 +279,6 @@ pub async fn all_questions_request(
let user = get_user_from_token!(jar, data.0);
let ignore_users = crate::ignore_users_gen!(user, data);
let list = match data.0.get_latest_global_questions(12, req.page).await {
Ok(l) => match data.0.fill_questions(l, &ignore_users).await {
Ok(l) => l,
@ -285,6 +292,7 @@ pub async fn all_questions_request(
context.insert("list", &list);
context.insert("page", &req.page);
Html(
data.1
.render("timelines/all_questions.html", &context)
@ -292,6 +300,50 @@ pub async fn all_questions_request(
)
}
/// `/all/forum_posts`
pub async fn all_forum_posts_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(req): Query<PaginatedQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Html(render_error(Error::NotAllowed, &jar, &data, &None).await);
}
};
let ignore_users = crate::ignore_users_gen!(user!, data);
let list = match data
.0
.get_latest_forum_posts(48, req.page, &Some(user.clone()), req.before)
.await
{
Ok(l) => match data
.0
.fill_posts_with_community(l, user.id, &ignore_users, &Some(user.clone()))
.await
{
Ok(l) => l,
Err(e) => return Html(render_error(e, &jar, &data, &Some(user)).await),
},
Err(e) => return Html(render_error(e, &jar, &data, &Some(user)).await),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("page", &req.page);
context.insert("feed", &list);
Html(
data.1
.render("timelines/all_forum_posts.html", &context)
.unwrap(),
)
}
#[derive(Deserialize)]
pub struct NotificationsProps {
#[serde(default)]
@ -637,15 +689,12 @@ pub struct TimelineQuery {
pub responses_only: bool,
}
/// `/_swiss_army_timeline`
pub async fn swiss_army_timeline_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(req): Query<TimelineQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
async fn swiss_army_timeline(
data: &InnerState,
user: Option<User>,
req: &TimelineQuery,
jar: &CookieJar,
) -> std::result::Result<Vec<FullPost>, Html<String>> {
let ignore_users = crate::ignore_users_gen!(user, data);
let list = if req.stack_id != 0 {
@ -759,12 +808,41 @@ pub async fn swiss_army_timeline_request(
None
},
),
Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)),
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)),
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
}
};
Ok(list)
}
/// `/_swiss_army_timeline`
pub async fn swiss_army_timeline_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(mut req): Query<TimelineQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
let mut empty_retries = 0; // how many times we've retried because of an empty list
let mut list = Vec::new();
while empty_retries < 2 {
list = match swiss_army_timeline(&data, user.clone(), &req, &jar).await {
Ok(x) => x,
Err(e) => return Err(e),
};
if list.len() == 0 && empty_retries != 2 {
empty_retries += 1;
req.page += 1;
} else {
break;
}
}
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;

View file

@ -2,11 +2,11 @@ pub mod auth;
pub mod chats;
pub mod communities;
pub mod developer;
pub mod economy;
pub mod forge;
pub mod journals;
pub mod littleweb;
pub mod mail;
pub mod marketplace;
pub mod misc;
pub mod mod_panel;
pub mod profile;
@ -41,6 +41,8 @@ pub fn routes() -> Router {
get(misc::following_questions_request),
)
.route("/all/questions", get(misc::all_questions_request))
// forum post timelines
.route("/all/forum_posts", get(misc::all_forum_posts_request))
// misc
.route("/notifs", get(misc::notifications_request))
.route("/requests", get(misc::requests_request))
@ -75,14 +77,6 @@ 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))
@ -91,6 +85,7 @@ pub fn routes() -> Router {
.route("/@{username}/replies", get(profile::replies_request))
.route("/@{username}/following", get(profile::following_request))
.route("/@{username}/followers", get(profile::followers_request))
.route("/@{username}/shop", get(profile::shop_request))
// communities
.route("/communities", get(communities::list_request))
.route("/communities/search", get(communities::search_request))
@ -113,6 +108,10 @@ pub fn routes() -> Router {
get(communities::members_request),
)
.route("/post/{id}", get(communities::post_request))
.route(
"/post/{id}/_quick_replies",
get(communities::forum_quick_replies_request),
)
.route("/post/{id}/reposts", get(communities::reposts_request))
.route("/post/{id}/likes", get(communities::likes_request))
.route("/question/{id}", get(communities::question_request))
@ -157,16 +156,19 @@ 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),
)
// mail
.route("/mail", get(mail::received_request))
.route("/mail/sent", get(mail::sent_request))
.route("/mail/compose", get(mail::compose_request))
.route("/mail/letter/{id}", get(mail::letter_request))
// economy
.route("/wallet", get(economy::wallet_request))
.route("/products", get(economy::products_request))
.route("/product/{id}/edit", get(economy::edit_product_request))
.route("/product/{id}", get(economy::product_request))
.route("/product/ad/{id}/edit", get(economy::edit_ad_request))
.route("/adn/random", get(economy::random_ad_request))
.route("/adn/{id}", get(economy::known_ad_request))
}
pub fn lw_routes() -> Router {
@ -189,6 +191,11 @@ pub async fn render_error(
pub struct PaginatedQuery {
#[serde(default)]
pub page: usize,
/// The list set on this page to be affected by the page increment.
///
/// This value depends on the page this query is for.
#[serde(default)]
pub page_set_id: usize,
#[serde(default)]
pub before: usize,
}

View file

@ -232,6 +232,7 @@ pub fn profile_context(
user: &Option<User>,
profile: &User,
communities: &Vec<Community>,
applied_configurations: Vec<String>,
is_self: bool,
is_following: bool,
is_following_you: bool,
@ -244,6 +245,7 @@ pub fn profile_context(
context.insert("is_following_you", &is_following_you);
context.insert("is_blocking", &is_blocking);
context.insert("warning_hash", &hash(profile.settings.warning.clone()));
context.insert("applied_configurations", &applied_configurations);
context.insert(
"is_supporter",
@ -307,20 +309,20 @@ pub async fn posts_request(
)
.await
{
Ok(p) => Some(data.0.posts_muted_phrase_filter(
Ok(p) => data.0.posts_muted_phrase_filter(
&p,
if let Some(ref ua) = user {
Some(&ua.settings.muted)
} else {
None
},
)),
),
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
}
} else {
None
Vec::new()
};
let communities = match data.0.get_memberships_by_owner(other_user.id).await {
@ -371,12 +373,15 @@ pub async fn posts_request(
context.insert("pinned", &pinned);
context.insert("page", &props.page);
context.insert("tag", &props.tag);
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -488,12 +493,15 @@ pub async fn replies_request(
context.insert("posts", &posts);
context.insert("page", &props.page);
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -601,12 +609,15 @@ pub async fn media_request(
context.insert("posts", &posts);
context.insert("page", &props.page);
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -617,6 +628,99 @@ pub async fn media_request(
Ok(Html(data.1.render("profile/media.html", &context).unwrap()))
}
/// `/@{username}/shop`
pub async fn shop_request(
jar: CookieJar,
Path(username): Path<String>,
Query(props): Query<PaginatedQuery>,
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 other_user = match data.0.get_user_by_username(&username).await {
Ok(ua) => ua,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if !other_user.settings.enable_shop {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &Some(user)).await,
));
}
check_user_blocked_or_private!(Some(user.clone()), other_user, data, jar);
// fetch data
let list = match data
.0
.get_products_by_user(other_user.id, 12, props.page)
.await
{
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let communities = match data.0.get_memberships_by_owner(other_user.id).await {
Ok(m) => match data.0.fill_communities(m).await {
Ok(m) => m,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
// init context
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await;
let is_self = user.id == other_user.id;
let is_following = data
.0
.get_userfollow_by_initiator_receiver(user.id, other_user.id)
.await
.is_ok();
let is_following_you = data
.0
.get_userfollow_by_receiver_initiator(user.id, other_user.id)
.await
.is_ok();
let is_blocking = data
.0
.get_userblock_by_initiator_receiver(user.id, other_user.id)
.await
.is_ok();
context.insert("list", &list);
context.insert("page", &props.page);
profile_context(
&mut context,
&Some(user.clone()),
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
is_self,
is_following,
is_following_you,
is_blocking,
);
// return
Ok(Html(data.1.render("profile/shop.html", &context).unwrap()))
}
/// `/@{username}/outbox`
pub async fn outbox_request(
jar: CookieJar,
@ -696,12 +800,15 @@ pub async fn outbox_request(
context.insert("questions", &questions);
context.insert("page", &props.page);
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&Some(user),
&Some(user.clone()),
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
is_self,
is_following,
is_following_you,
@ -806,12 +913,15 @@ pub async fn following_request(
context.insert("list", &list);
context.insert("page", &props.page);
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -916,12 +1026,15 @@ pub async fn followers_request(
context.insert("list", &list);
context.insert("page", &props.page);
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
profile_context(
&mut context,
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,

View file

@ -9,9 +9,9 @@ pub fn color_escape(color: &str) -> String {
.replace(">", "%gt;")
.replace("}", "")
.replace("{", "")
.replace("url(\"", "url(\"/api/v0/util/ext/image?img=")
.replace("url('", "url('/api/v0/util/ext/image?img=")
.replace("url(https://", "url(/api/v0/util/ext/image?img=https://"),
.replace("url(\"", "url(\"/api/v1/util/proxy?url=")
.replace("url('", "url('/api/v1/util/proxy?url=")
.replace("url(https://", "url(/api/v1/util/proxy?url=https://"),
)
}

View file

@ -1,7 +1,7 @@
[package]
name = "tetratto-core"
description = "The core behind Tetratto"
version = "14.0.0"
version = "15.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -184,7 +184,7 @@ pub struct StripeConfig {
pub payment_links: StripePaymentLinks,
/// To apply benefits to user accounts, you should then go into the Stripe developer
/// "workbench" and create a new webhook. The webhook needs the scopes:
/// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`.
/// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`, `charge.succeeded`.
///
/// The webhook's destination address should be `{your server origin}/api/v1/service_hooks/stripe`.
///
@ -200,12 +200,16 @@ pub struct StripeConfig {
///
/// These are checked when we receive a webhook to ensure we provide the correct product.
pub product_ids: StripeProductIds,
/// The IDs of individual prices for products which require us to generate sessions ourselves.
pub price_ids: StripePriceIds,
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
pub struct StripePriceTexts {
pub supporter: String,
pub dev_pass: String,
pub coins_100: String,
pub coins_400: String,
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
@ -218,6 +222,14 @@ pub struct StripePaymentLinks {
pub struct StripeProductIds {
pub supporter: String,
pub dev_pass: String,
pub coins_100: String,
pub coins_400: String,
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
pub struct StripePriceIds {
pub coins_100: String,
pub coins_400: String,
}
/// Manuals config (search help, etc)
@ -308,6 +320,16 @@ pub struct Config {
/// This community **must** have open write access.
#[serde(default)]
pub town_square: usize,
/// The ID of the town square forum community.
#[serde(default)]
pub town_square_forum: usize,
/// The ID of the topic within the town square forum community that users are prompted
/// to post in by default. This should be some sort of "general" topic.
#[serde(default)]
pub town_square_forum_topic: usize,
/// The ID of the "system" user which will send system mails to users.
#[serde(default)]
pub system_user: usize,
#[serde(default)]
pub connections: ConnectionsConfig,
/// The path to the HTML footer file. The contents of this file are embedded
@ -322,6 +344,9 @@ pub struct Config {
/// A list of banned content in posts.
#[serde(default)]
pub banned_data: Vec<StringBan>,
/// If user ads are enabled.
#[serde(default)]
pub enable_user_ads: bool,
}
fn default_name() -> String {
@ -389,6 +414,9 @@ fn default_banned_usernames() -> Vec<String> {
"services".to_string(),
"domains".to_string(),
"mail".to_string(),
"product".to_string(),
"wallet".to_string(),
"products".to_string(),
]
}
@ -430,11 +458,15 @@ impl Default for Config {
policies: default_policies(),
turnstile: default_turnstile(),
town_square: 0,
town_square_forum: 0,
town_square_forum_topic: 0,
system_user: 0,
connections: default_connections(),
html_footer_path: String::new(),
stripe: None,
manuals: default_manuals(),
banned_data: default_banned_data(),
enable_user_ads: false,
}
}
}

View file

@ -0,0 +1,268 @@
use crate::model::{
auth::{User, UserWarning},
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, UserAd, UserAdSize},
permissions::FinePermission,
Error, Result,
};
use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
use tetratto_shared::unix_epoch_timestamp;
impl DataManager {
/// Get a [`UserAd`] from an SQL row.
pub(crate) fn get_ad_from_row(x: &PostgresRow) -> UserAd {
UserAd {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize,
upload_id: get!(x->3(i64)) as usize,
target: get!(x->4(String)),
last_charge_time: get!(x->5(i64)) as usize,
is_running: get!(x->6(i32)) as i8 == 1,
size: serde_json::from_str(&get!(x->7(String))).unwrap(),
}
}
auto_method!(get_ad_by_id(usize as i64)@get_ad_from_row -> "SELECT * FROM ads WHERE id = $1" --name="ad" --returns=UserAd --cache-key-tmpl="atto.ad:{}");
/// Get all ads by user.
///
/// # Arguments
/// * `id` - the ID of the user to fetch ads for
/// * `batch` - the limit of items in each page
/// * `page` - the page number
pub async fn get_ads_by_user(
&self,
id: usize,
batch: usize,
page: usize,
) -> Result<Vec<UserAd>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM ads WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|x| { Self::get_ad_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("ad".to_string()));
}
Ok(res.unwrap())
}
/// Disable all ads by the given user.
///
/// # Arguments
/// * `id` - the ID of the user to kill ads from
pub async fn stop_all_ads_by_user(&self, id: usize) -> Result<Vec<UserAd>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"UPDATE ads SET is_running = 0 WHERE owner = $1",
&[&(id as i64)],
|x| { Self::get_ad_from_row(x) }
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(res.unwrap())
}
/// Create a new ad in the database.
///
/// # Arguments
/// * `data` - a mock [`UserAd`] object to insert
pub async fn create_ad(&self, data: UserAd) -> Result<UserAd> {
// check values
if data.target.len() < 2 {
return Err(Error::DataTooShort("description".to_string()));
} else if data.target.len() > 256 {
return Err(Error::DataTooLong("description".to_string()));
}
// charge for first day
if data.is_running {
self.create_transfer(
&mut CoinTransfer::new(
data.owner,
self.0.0.system_user,
Self::AD_RUN_CHARGE,
CoinTransferMethod::Transfer,
CoinTransferSource::AdCharge,
),
true,
)
.await?;
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"INSERT INTO ads VALUES (DEFAULT, $1, $2, $3, $4, $5, $6, $7)",
params![
&(data.created as i64),
&(data.owner as i64),
&(data.upload_id as i64),
&data.target,
&(data.last_charge_time as i64),
&if data.is_running { 1 } else { 0 },
&serde_json::to_string(&data.size).unwrap()
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(data)
}
pub async fn delete_ad(&self, id: usize, user: &User) -> Result<()> {
let ad = self.get_ad_by_id(id).await?;
// check user permission
if user.id != ad.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Err(Error::NotAllowed);
}
// remove upload
self.delete_upload(ad.upload_id).await?;
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(&conn, "DELETE FROM ads WHERE id = $1", &[&(id as i64)]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// ...
self.0.1.remove(format!("atto.ad:{}", id)).await;
Ok(())
}
/// Pull a random running ad.
pub async fn random_ad(&self, size: UserAdSize) -> Result<UserAd> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(
&conn,
"SELECT * FROM ads WHERE is_running = 1 AND size = $1 ORDER BY RANDOM() DESC LIMIT 1",
&[&serde_json::to_string(&size).unwrap()],
|x| { Ok(Self::get_ad_from_row(x)) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("ad".to_string()));
}
Ok(res.unwrap())
}
const MINIMUM_DELTA_FOR_CHARGE: usize = 604_800_000; // 7 days
/// The amount charged to a [`UserAd`] owner each day the ad is running (and is pulled from the pool).
pub const AD_RUN_CHARGE: i32 = 25;
/// The amount charged to a [`UserAd`] owner each time the ad is clicked.
pub const AD_CLICK_CHARGE: i32 = 2;
/// Get a random ad and check if the ad owner needs to be charged for this period.
pub async fn random_ad_charged(&self, size: UserAdSize) -> Result<UserAd> {
let ad = self.random_ad(size).await?;
let now = unix_epoch_timestamp();
let delta = now - ad.last_charge_time;
if delta >= Self::MINIMUM_DELTA_FOR_CHARGE {
if let Err(e) = self
.create_transfer(
&mut CoinTransfer::new(
ad.owner,
self.0.0.system_user,
Self::AD_RUN_CHARGE,
CoinTransferMethod::Transfer,
CoinTransferSource::AdCharge,
),
true,
)
.await
{
// boo user cannot afford to keep running their ads
self.stop_all_ads_by_user(ad.owner).await?;
return Err(e);
};
self.update_ad_last_charge_time(ad.id, now as i64).await?;
}
Ok(ad)
}
/// Handle a click on an ad from the given host.
///
/// Hosts are just the ID of the user that is embedding the ad on their page.
pub async fn ad_click(&self, host: usize, ad: usize, user: Option<User>) -> Result<String> {
let ad = self.get_ad_by_id(ad).await?;
if let Some(ref ua) = user {
if ua.id == host {
self.create_user_warning(
UserWarning::new(
ua.id,
self.0.0.system_user,
"Automated warning: do not click on ads on your own site! This incident has been reported.".to_string()
)
).await?;
return Ok(ad.target);
}
}
// create click transfer
if let Err(e) = self
.create_transfer(
&mut CoinTransfer::new(
ad.owner,
host,
Self::AD_CLICK_CHARGE,
CoinTransferMethod::Transfer,
CoinTransferSource::AdClick,
),
true,
)
.await
{
self.stop_all_ads_by_user(ad.owner).await?;
return Err(e);
}
// return
Ok(ad.target)
}
auto_method!(update_ad_is_running(i32)@get_ad_by_id:FinePermission::MANAGE_USERS; -> "UPDATE ads SET is_running = $1 WHERE id = $2" --cache-key-tmpl="atto.ad:{}");
auto_method!(update_ad_last_charge_time(i64) -> "UPDATE ads SET last_charge_time = $1 WHERE id = $2" --cache-key-tmpl="atto.ad:{}");
}

View file

@ -64,7 +64,7 @@ impl DataManager {
/// * `data` - a mock [`ThirdPartyApp`] object to insert
pub async fn create_app(&self, data: ThirdPartyApp) -> Result<ThirdPartyApp> {
// check values
if data.title.len() < 2 {
if data.title.trim().len() < 2 {
return Err(Error::DataTooShort("title".to_string()));
} else if data.title.len() > 32 {
return Err(Error::DataTooLong("title".to_string()));

View file

@ -1,16 +1,15 @@
use super::common::NAME_REGEX;
use oiseau::cache::Cache;
use crate::model::auth::{
Achievement, AchievementName, AchievementRarity, Notification, StripeSellerData,
UserConnections, ACHIEVEMENTS,
};
use crate::model::moderation::AuditLogEntry;
use crate::model::oauth::AuthGrant;
use crate::model::permissions::SecondaryPermission;
use crate::model::{
Error, Result,
auth::{Token, User, UserSettings},
permissions::FinePermission,
permissions::{FinePermission, SecondaryPermission},
oauth::AuthGrant,
moderation::AuditLogEntry,
auth::{
Achievement, AchievementName, AchievementRarity, Notification, UserConnections,
ACHIEVEMENTS, AppliedConfigType,
},
};
use pathbufd::PathBufD;
use std::fs::{exists, remove_file};
@ -27,7 +26,7 @@ macro_rules! update_role_fn {
&self,
id: usize,
role: $role_ty,
user: User,
user: &User,
force: bool,
) -> Result<()> {
let other_user = self.get_user_by_id(id).await?;
@ -125,10 +124,13 @@ impl DataManager {
awaiting_purchase: get!(x->24(i32)) as i8 == 1,
was_purchased: get!(x->25(i32)) as i8 == 1,
browser_session: get!(x->26(String)),
seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(),
ban_reason: get!(x->28(String)),
channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(),
is_deactivated: get!(x->30(i32)) as i8 == 1,
ban_reason: get!(x->27(String)),
channel_mutes: serde_json::from_str(&get!(x->28(String)).to_string()).unwrap(),
is_deactivated: get!(x->29(i32)) as i8 == 1,
ban_expire: get!(x->30(i64)) as usize,
coins: get!(x->31(i32)),
checkouts: serde_json::from_str(&get!(x->32(String)).to_string()).unwrap(),
applied_configurations: serde_json::from_str(&get!(x->33(String)).to_string()).unwrap(),
}
}
@ -285,7 +287,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)",
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34)",
params![
&(data.id as i64),
&(data.created as i64),
@ -314,10 +316,13 @@ impl DataManager {
&if data.awaiting_purchase { 1_i32 } else { 0_i32 },
&if data.was_purchased { 1_i32 } else { 0_i32 },
&data.browser_session,
&serde_json::to_string(&data.seller_data).unwrap(),
&data.ban_reason,
&serde_json::to_string(&data.channel_mutes).unwrap(),
&if data.is_deactivated { 1_i32 } else { 0_i32 },
&(data.ban_expire as i64),
&(data.coins as i32),
&serde_json::to_string(&data.checkouts).unwrap(),
&serde_json::to_string(&data.applied_configurations).unwrap(),
]
);
@ -535,6 +540,68 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
// delete transfers
let res = execute!(
&conn,
"DELETE FROM transfers WHERE sender = $1 OR receiver = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete products
let res = execute!(
&conn,
"DELETE FROM products WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete domains
let res = execute!(
&conn,
"DELETE FROM domains WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete services
let res = execute!(
&conn,
"DELETE FROM services WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete letters
let res = execute!(
&conn,
"DELETE FROM letters WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete ads
let res = execute!(&conn, "DELETE FROM ads WHERE owner = $1", &[&(id as i64)]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete user follows... individually since it requires updating user counts
for follow in self.get_userfollows_by_receiver_all(id).await? {
self.delete_userfollow(follow.id, &user, true).await?;
@ -1033,6 +1100,38 @@ impl DataManager {
Ok((totp.get_secret_base32(), qr, recovery))
}
/// Get all applied configurations as a vector of strings from the given user.
pub async fn get_applied_configurations(&self, user: &User) -> Result<Vec<String>> {
let mut out = Vec::new();
for config in &user.applied_configurations {
let product = self.get_product_by_id(config.1).await?;
let owner = self.get_user_by_id_with_void(product.owner).await?;
if config.0 == AppliedConfigType::StyleSnippet
&& !owner.permissions.check(FinePermission::SUPPORTER)
{
out.push(format!(
"<script>console.warn(\"{} has stopped their supporter subscription, so this applied configuration no longer works.\");</script>",
owner.username
));
continue;
}
out.push(match config.0 {
AppliedConfigType::StyleSnippet => {
format!(
"<style>{}</style>",
product.data.replace("<", "&lt;").replace(">", "&gt;")
)
}
})
}
Ok(out)
}
pub async fn cache_clear_user(&self, user: &User) {
self.0.1.remove(format!("atto.user:{}", user.id)).await;
self.0
@ -1056,9 +1155,12 @@ impl DataManager {
auto_method!(update_user_achievements(Vec<Achievement>)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_channel_mutes(Vec<usize>)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_coins(i32)@get_user_by_id -> "UPDATE users SET coins = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_checkouts(Vec<String>)@get_user_by_id -> "UPDATE users SET checkouts = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_applied_configurations(Vec<(AppliedConfigType, usize)>)@get_user_by_id -> "UPDATE users SET applied_configurations = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User);
auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);

View file

@ -42,9 +42,11 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap();
execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap();
execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap();
execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap();
execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap();
execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap();
execute!(&conn, common::CREATE_TABLE_TRANSFERS).unwrap();
execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap();
execute!(&conn, common::CREATE_TABLE_ADS).unwrap();
for x in common::VERSION_MIGRATIONS.split(";") {
execute!(&conn, x).unwrap();

View file

@ -207,7 +207,7 @@ impl DataManager {
/// * `data` - a mock [`Community`] to insert
pub async fn create_community(&self, data: Community) -> Result<String> {
// check values
if data.title.len() < 2 {
if data.title.trim().len() < 2 {
return Err(Error::DataTooShort("title".to_string()));
} else if data.title.len() > 32 {
return Err(Error::DataTooLong("title".to_string()));
@ -556,6 +556,7 @@ impl DataManager {
auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_topics(HashMap<usize, ForumTopic>)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET topics = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_is_forum(i32)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET is_forum = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_community);
auto_method!(incr_community_likes()@get_community_by_id_no_void -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
auto_method!(incr_community_dislikes()@get_community_by_id_no_void -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);

View file

@ -82,7 +82,7 @@ impl DataManager {
/// * `data` - a mock [`Domain`] object to insert
pub async fn create_domain(&self, data: Domain) -> Result<Domain> {
// check values
if data.name.len() < 2 {
if data.name.trim().len() < 2 {
return Err(Error::DataTooShort("name".to_string()));
} else if data.name.len() > 128 {
return Err(Error::DataTooLong("name".to_string()));

View file

@ -30,6 +30,8 @@ pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_mess
pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql");
pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql");
pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql");
pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql");
pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql");
pub const CREATE_TABLE_LETTERS: &str = include_str!("./sql/create_letters.sql");
pub const CREATE_TABLE_TRANSFERS: &str = include_str!("./sql/create_transfers.sql");
pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql");
pub const CREATE_TABLE_ADS: &str = include_str!("./sql/create_ads.sql");

View file

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS ads (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
upload_id BIGINT NOT NULL,
target TEXT NOT NULL,
last_charge_time BIGINT NOT NULL,
is_running INT NOT NULL,
size TEXT NOT NULL
)

View file

@ -2,11 +2,13 @@ CREATE TABLE IF NOT EXISTS products (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
name TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
likes INT NOT NULL,
dislikes INT NOT NULL,
product_type TEXT NOT NULL,
price TEXT NOT NULL,
method TEXT NOT NULL,
on_sale INT NOT NULL,
price INT NOT NULL,
stock INT NOT NULL,
single_use INT NOT NULL,
data TEXT NOT NULL,
uploads TEXT NOT NULL
)

View file

@ -4,5 +4,6 @@ CREATE TABLE IF NOT EXISTS requests (
owner BIGINT NOT NULL,
action_type TEXT NOT NULL,
linked_asset BIGINT NOT NULL,
data TEXT NOT NULL,
PRIMARY KEY (id, owner, linked_asset)
)

View file

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS transfers (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
sender BIGINT NOT NULL,
receiver BIGINT NOT NULL,
amount INT NOT NULL,
is_pending INT NOT NULL,
method TEXT NOT NULL,
source TEXT NOT NULL
)

View file

@ -26,8 +26,11 @@ CREATE TABLE IF NOT EXISTS users (
awaiting_purchase INT NOT NULL,
was_purchased INT NOT NULL,
browser_session TEXT NOT NULL,
seller_data TEXT NOT NULL,
ban_reason TEXT NOT NULL,
channel_mutes TEXT NOT NULL,
is_deactivated INT NOT NULL
is_deactivated INT NOT NULL,
ban_expire BIGINT NOT NULL,
coins INT NOT NULL,
checkouts TEXT NOT NULL,
applied_configurations TEXT NOT NULL
)

View file

@ -25,3 +25,43 @@ ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}';
-- posts topic
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS topic BIGINT DEFAULT 0;
-- users ban_expire
ALTER TABLE users
ADD COLUMN IF NOT EXISTS ban_expire BIGINT DEFAULT 0;
-- remove users seller_data
ALTER TABLE users
DROP COLUMN IF EXISTS seller_data;
-- users coins
ALTER TABLE users
ADD COLUMN IF NOT EXISTS coins INT DEFAULT 0;
-- requests data
ALTER TABLE requests
ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '"Null"';
-- users checkouts
ALTER TABLE users
ADD COLUMN IF NOT EXISTS checkouts TEXT DEFAULT '[]';
-- products single_use
ALTER TABLE products
ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1;
-- transfers source
ALTER TABLE transfers
ADD COLUMN IF NOT EXISTS source TEXT DEFAULT '"General"';
-- products data
ALTER TABLE products
ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '';
-- users applied_configurations
ALTER TABLE users
ADD COLUMN IF NOT EXISTS applied_configurations TEXT DEFAULT '[]';
-- products uploads
ALTER TABLE products
ADD COLUMN IF NOT EXISTS uploads TEXT DEFAULT '{}';

View file

@ -1,5 +1,6 @@
use std::collections::HashMap;
use crate::model::auth::Notification;
use crate::model::{auth::User, mail::Letter, permissions::SecondaryPermission, Error, Result};
use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow};
@ -160,6 +161,9 @@ impl DataManager {
return Err(Error::DataTooLong("receivers".to_string()));
}
// get sender
let sender = self.get_user_by_id(data.owner).await?;
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
@ -185,6 +189,20 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
// send notifications
for x in &data.receivers {
self.create_notification(Notification::new(
"You've got mail!".to_string(),
format!(
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/letter/{}).",
sender.username, sender.id, data.id
),
*x,
))
.await?;
}
// ...
Ok(data)
}

View file

@ -200,6 +200,7 @@ impl DataManager {
community.owner,
ActionType::CommunityJoin,
community.id,
None,
))
.await?;

View file

@ -1,3 +1,4 @@
mod ads;
pub mod app_data;
mod apps;
mod audit_log;
@ -31,6 +32,7 @@ mod requests;
mod services;
mod stackblocks;
mod stacks;
mod transfers;
mod uploads;
mod user_warnings;
mod userblocks;
@ -38,3 +40,4 @@ mod userfollows;
pub use drivers::DataManager;
pub use common::NAME_REGEX;
pub use posts::FullPost;

View file

@ -13,9 +13,7 @@ use crate::model::{
};
use tetratto_shared::unix_epoch_timestamp;
use crate::{auto_method, DataManager};
use oiseau::{PostgresRow, cache::redis::Commands};
use oiseau::{execute, get, query_row, query_rows, params, cache::Cache};
use oiseau::{PostgresRow, execute, get, query_row, query_rows, params, cache::Cache};
pub type FullPost = (
Post,
@ -816,95 +814,6 @@ impl DataManager {
Ok(res.unwrap())
}
/// Calculate the GPA (great post average) of a given user.
///
/// To be considered a "great post", a post must have a score ((likes - dislikes) / (likes + dislikes))
/// of at least 0.6.
///
/// GPA is calculated based on the user's last 48 posts.
pub async fn calculate_user_gpa(&self, id: usize) -> f32 {
// just for note, this is SUPER bad for performance... which is why we
// only calculate this when it expires in the cache (every day)
if let Some(cached) = self.0.1.get(format!("atto.user.gpa:{}", id)).await {
if let Ok(c) = cached.parse() {
return c;
}
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(_) => return 0.0,
};
let res = query_rows!(
&conn,
&format!("SELECT * FROM posts WHERE owner = $1 ORDER BY created DESC LIMIT 48"),
&[&(id as i64)],
|x| { Self::get_post_from_row(x) }
);
if res.is_err() {
return 0.0;
}
// ...
let mut real_posts_count: usize = 0; // posts which can be scored
let mut good_posts: usize = 0;
// let mut bad_posts: usize = 0;
let posts = res.unwrap();
for post in posts {
if post.likes == 0 && post.dislikes == 0 {
// post has no likes or dislikes... doesn't count
if good_posts > 8 {
good_posts -= 1; // we're going to say this is a bad post because it isn't liked enough
}
continue;
}
real_posts_count += 1;
// likes percentage / total likes
let score: f32 = (post.likes as f32 - post.dislikes as f32)
/ (post.likes as f32 + post.dislikes as f32);
if score.is_sign_negative() {
// bad_posts += 1;
continue;
}
if score > 0.6 {
good_posts += 1;
}
// } else {
// bad_posts += 1;
// }
}
let gpa = (good_posts as f32 / real_posts_count as f32) * 4.0;
let gpa_rounded = format!("{gpa:.2}").parse::<f32>().unwrap();
let mut redis_con = self.0.1.get_con().await;
// expires in one day
if redis_con
.set_ex::<String, String, usize>(
format!("atto.user.gpa:{}", id),
gpa_rounded.to_string(),
86400,
)
.is_err()
{
return 0.0;
};
// ...
gpa_rounded
}
/// Get all replies from the given user (from most recent).
///
/// # Arguments
@ -1606,6 +1515,57 @@ impl DataManager {
Ok(res.unwrap())
}
/// Get forum posts from all communities, sorted by creation.
///
/// # Arguments
/// * `batch` - the limit of posts in each page
/// * `page` - the page number
pub async fn get_latest_forum_posts(
&self,
batch: usize,
page: usize,
as_user: &Option<User>,
before_time: usize,
) -> Result<Vec<Post>> {
// check if we should hide nsfw posts
let mut hide_nsfw: bool = true;
if let Some(ua) = as_user {
hide_nsfw = !ua.settings.show_nsfw;
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
&format!(
"SELECT * FROM posts WHERE replying_to = 0{}{} AND NOT context LIKE '%\"full_unlist\":true%' AND NOT topic = 0 ORDER BY created DESC LIMIT $1 OFFSET $2",
if before_time > 0 {
format!(" AND created < {before_time}")
} else {
String::new()
},
if hide_nsfw {
" AND NOT context LIKE '%\"is_nsfw\":true%'"
} else {
""
}
),
&[&(batch as i64), &((page * batch) as i64)],
|x| { Self::get_post_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("post".to_string()));
}
Ok(res.unwrap())
}
/// Get posts from all communities the given user is in.
///
/// # Arguments
@ -1783,6 +1743,28 @@ impl DataManager {
}
}
/// Check if the given `uid` can post in the given `community` with the given `access`.
pub async fn check_can_post_with_access(
&self,
community: &Community,
access: &CommunityWriteAccess,
uid: usize,
) -> bool {
match *access {
CommunityWriteAccess::Owner => uid == community.owner,
CommunityWriteAccess::Joined => {
match self
.get_membership_by_owner_community(uid, community.id)
.await
{
Ok(m) => m.role.check_member(),
Err(_) => false,
}
}
_ => true,
}
}
/// Create a new post in the database.
///
/// # Arguments
@ -1840,9 +1822,19 @@ impl DataManager {
));
}
if community.topics.get(&data.topic).is_none() {
if let Some(topic) = community.topics.get(&data.topic) {
// check permission
if !self
.check_can_post_with_access(&community, &topic.write_access, data.owner)
.await
{
return Err(Error::NotAllowed);
}
} else {
return Err(Error::GeneralNotFound("topic".to_string()));
}
} else if data.topic != 0 {
return Err(Error::DoesNotSupportField("Community".to_string()));
}
// ...
@ -1870,7 +1862,7 @@ impl DataManager {
));
}
} else if data.replying_to.is_none() {
if data.title.len() < 2 && community.context.require_titles {
if data.title.trim().len() < 2 && community.context.require_titles {
return Err(Error::DataTooShort("title".to_string()));
} else if data.title.len() > 128 {
return Err(Error::DataTooLong("title".to_string()));
@ -2229,7 +2221,9 @@ impl DataManager {
// decr parent comment count
if let Some(replying_to) = y.replying_to {
self.decr_post_comments(replying_to).await.unwrap();
if replying_to != 0 {
self.decr_post_comments(replying_to).await.unwrap();
}
}
// decr user post count

View file

@ -1,7 +1,11 @@
use crate::model::{
auth::User,
permissions::{FinePermission, SecondaryPermission},
products::{Product, ProductPrice},
economy::{
CoinTransfer, CoinTransferMethod, CoinTransferSource, Product, ProductFulfillmentMethod,
ProductUploads,
},
mail::Letter,
permissions::FinePermission,
Error, Result,
};
use crate::{auto_method, DataManager};
@ -14,13 +18,15 @@ impl DataManager {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize,
name: get!(x->3(String)),
title: get!(x->3(String)),
description: get!(x->4(String)),
likes: get!(x->5(i32)) as isize,
dislikes: get!(x->6(i32)) as isize,
product_type: serde_json::from_str(&get!(x->7(String))).unwrap(),
price: serde_json::from_str(&get!(x->8(String))).unwrap(),
uploads: serde_json::from_str(&get!(x->9(String))).unwrap(),
method: serde_json::from_str(&get!(x->5(String))).unwrap(),
on_sale: get!(x->6(i32)) as i8 == 1,
price: get!(x->7(i32)),
stock: get!(x->8(i32)),
single_use: get!(x->9(i32)) as i8 == 1,
data: get!(x->10(String)),
uploads: serde_json::from_str(&get!(x->11(String))).unwrap(),
}
}
@ -30,8 +36,8 @@ impl DataManager {
///
/// # Arguments
/// * `id` - the ID of the user to fetch products for
/// * `batch`
/// * `page`
/// * `batch` - the limit of items in each page
/// * `page` - the page number
pub async fn get_products_by_user(
&self,
id: usize,
@ -45,7 +51,7 @@ impl DataManager {
let res = query_rows!(
&conn,
"SELECT * FROM products WHERE owner = $1 ORDER BY created DESC LIMIT {} OFFSET {}",
"SELECT * FROM products WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|x| { Self::get_product_from_row(x) }
);
@ -57,45 +63,30 @@ impl DataManager {
Ok(res.unwrap())
}
/// Get all products by user.
///
/// # Arguments
/// * `id` - the ID of the user to fetch products for
pub async fn get_products_by_user_all(&self, id: usize) -> Result<Vec<Product>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM products WHERE owner = $1 ORDER BY created DESC",
&[&(id as i64)],
|x| { Self::get_product_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("product".to_string()));
}
Ok(res.unwrap())
}
const MAXIMUM_FREE_PRODUCTS: usize = 15;
const MAXIMUM_FREE_PRODUCTS: usize = 10;
/// Create a new product in the database.
///
/// # Arguments
/// * `data` - a mock [`Product`] object to insert
pub async fn create_product(&self, data: Product) -> Result<Product> {
pub async fn create_product(&self, mut data: Product) -> Result<Product> {
data.title = data.title.trim().to_string();
data.description = data.description.trim().to_string();
// check values
if data.name.len() < 2 {
return Err(Error::DataTooShort("name".to_string()));
} else if data.name.len() > 128 {
return Err(Error::DataTooLong("name".to_string()));
if data.title.len() < 2 {
return Err(Error::DataTooShort("title".to_string()));
} else if data.title.len() > 128 {
return Err(Error::DataTooLong("title".to_string()));
}
// check number of products
if data.description.len() < 2 {
return Err(Error::DataTooShort("description".to_string()));
} else if data.description.len() > 1024 {
return Err(Error::DataTooLong("description".to_string()));
}
// check number of stacks
let owner = self.get_user_by_id(data.owner).await?;
if !owner.permissions.check(FinePermission::SUPPORTER) {
@ -118,17 +109,19 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
"INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
params![
&(data.id as i64),
&(data.created as i64),
&(data.owner as i64),
&data.name,
&data.title,
&data.description,
&0_i32,
&0_i32,
&serde_json::to_string(&data.product_type).unwrap(),
&serde_json::to_string(&data.price).unwrap(),
&serde_json::to_string(&data.method).unwrap(),
&{ if data.on_sale { 1 } else { 0 } },
&data.price,
&(data.stock as i32),
&{ if data.single_use { 1 } else { 0 } },
&data.data,
&serde_json::to_string(&data.uploads).unwrap(),
]
);
@ -140,18 +133,132 @@ impl DataManager {
Ok(data)
}
/// Purchase the given product as the given user.
pub async fn purchase_product(
&self,
product: usize,
customer: &mut User,
) -> Result<CoinTransfer> {
let product = self.get_product_by_id(product).await?;
// handle single_use product
if product.single_use {
if self
.get_transfer_by_sender_method(
customer.id,
CoinTransferMethod::Purchase(product.id),
)
.await
.is_ok()
{
return Err(Error::MiscError("You already own this product".to_string()));
}
}
// ...
let mut transfer = CoinTransfer::new(
customer.id,
product.owner,
product.price,
CoinTransferMethod::Purchase(product.id),
CoinTransferSource::Sale,
);
if !product.stock.is_negative() {
// check stock
if product.stock == 0 {
return Err(Error::MiscError("No remaining stock".to_string()));
} else {
self.decr_product_stock(product.id).await?;
}
}
match product.method {
ProductFulfillmentMethod::AutoMail(message) => {
// we're basically done, transfer coins and send mail
self.create_transfer(&mut transfer, true).await?;
self.create_letter(Letter::new(
self.0.0.system_user,
vec![customer.id],
format!("Thank you for purchasing \"{}\"", product.title),
format!("The message below was supplied by the product owner, and was automatically sent.\n***\n{message}"),
0,
))
.await?;
Ok(transfer)
}
ProductFulfillmentMethod::ManualMail => {
// mark transfer as pending and create it
self.create_transfer(&mut transfer, false).await?;
// tell the customer to wait
self.create_letter(Letter::new(
self.0.0.system_user,
vec![customer.id],
format!("Thank you for purchasing \"{}\"", product.title),
"This product uses manual mail, meaning you won't be charged until the product owner sends you a letter about the product. You'll see a pending transfer in your wallet.".to_string(),
0,
))
.await?;
// tell product owner they have a new pending purchase
self.create_letter(Letter::new(
self.0.0.system_user,
vec![product.owner],
"New product purchase pending".to_string(),
format!(
"Somebody has purchased your [product](/product/{}) \"{}\". Per your product's settings, the payment will not be completed until you manually mail them a letter **using the link below**.
If your product is a purchase of goods or services, please be sure to fulfill this purchase either in the letter or elsewhere. The customer may request support if you fail to do so.
***
<a class=\"button\" href=\"/mail/compose?receivers=id:{}&subject=Product%20fulfillment&transfer_id={}\">Fulfill purchase</a>",
product.id, product.title, customer.id, transfer.id
),
0,
))
.await?;
// return
Ok(transfer)
}
ProductFulfillmentMethod::ProfileStyle => {
// pretty much an automail without the message
self.create_transfer(&mut transfer, true).await?;
self.create_letter(Letter::new(
self.0.0.system_user,
vec![customer.id],
format!("Thank you for purchasing \"{}\"", product.title),
"You've purchased a CSS snippet which can be applied to your profile through the product's page!".to_string(),
0,
))
.await?;
Ok(transfer)
}
}
}
pub async fn delete_product(&self, id: usize, user: &User) -> Result<()> {
let product = self.get_product_by_id(id).await?;
// check user permission
if user.id != product.owner
&& !user
.secondary_permissions
.check(SecondaryPermission::MANAGE_PRODUCTS)
{
if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Err(Error::NotAllowed);
}
// remove uploads
for upload in product.uploads.thumbnails {
self.delete_upload(upload).await?;
}
if product.uploads.reward != 0 {
self.delete_upload(product.uploads.reward).await?;
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
@ -169,7 +276,16 @@ impl DataManager {
Ok(())
}
auto_method!(update_product_name(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_title(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_description(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET description = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_price(ProductPrice)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_price(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_on_sale(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET on_sale = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_method(ProductFulfillmentMethod)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET method = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_single_use(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET single_use = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_data(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET data = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_uploads(ProductUploads)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET uploads = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_stock(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET stock = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(incr_product_stock() -> "UPDATE products SET stock = stock + 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --incr);
auto_method!(decr_product_stock()@get_product_by_id -> "UPDATE products SET stock = stock - 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --decr=stock);
}

View file

@ -506,6 +506,7 @@ impl DataManager {
data.receiver,
ActionType::Answer,
data.id,
None,
))
.await?;
}

View file

@ -14,6 +14,7 @@ impl DataManager {
owner: get!(x->2(i64)) as usize,
action_type: serde_json::from_str(&get!(x->3(String))).unwrap(),
linked_asset: get!(x->4(i64)) as usize,
data: serde_json::from_str(&get!(x->5(String))).unwrap(),
}
}
@ -28,7 +29,14 @@ impl DataManager {
.get(format!("atto.request:{}:{}", id, linked_asset))
.await
{
return Ok(serde_json::from_str(&cached).unwrap());
if let Ok(x) = serde_json::from_str(&cached) {
return Ok(x);
} else {
self.0
.1
.remove(format!("atto.request:{}:{}", id, linked_asset))
.await;
}
}
let conn = match self.0.connect().await {
@ -118,13 +126,14 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO requests VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO requests VALUES ($1, $2, $3, $4, $5, $6)",
params![
&(data.id as i64),
&(data.created as i64),
&(data.owner as i64),
&serde_json::to_string(&data.action_type).unwrap().as_str(),
&(data.linked_asset as i64),
&serde_json::to_string(&data.data).unwrap().as_str(),
]
);

View file

@ -54,7 +54,7 @@ impl DataManager {
/// * `data` - a mock [`Service`] object to insert
pub async fn create_service(&self, data: Service) -> Result<Service> {
// check values
if data.name.len() < 2 {
if data.name.trim().len() < 2 {
return Err(Error::DataTooShort("name".to_string()));
} else if data.name.len() > 128 {
return Err(Error::DataTooLong("name".to_string()));

View file

@ -155,7 +155,7 @@ impl DataManager {
/// * `data` - a mock [`UserStack`] object to insert
pub async fn create_stack(&self, data: UserStack) -> Result<UserStack> {
// check values
if data.name.len() < 2 {
if data.name.trim().len() < 2 {
return Err(Error::DataTooShort("title".to_string()));
} else if data.name.len() > 32 {
return Err(Error::DataTooLong("title".to_string()));

View file

@ -0,0 +1,254 @@
use std::collections::HashMap;
use crate::model::{
auth::{Notification, User},
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, Product},
Error, Result,
};
use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
impl DataManager {
/// Get a [`CoinTransfer`] from an SQL row.
pub(crate) fn get_transfer_from_row(x: &PostgresRow) -> CoinTransfer {
CoinTransfer {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
sender: get!(x->2(i64)) as usize,
receiver: get!(x->3(i64)) as usize,
amount: get!(x->4(i32)),
is_pending: get!(x->5(i32)) as i8 == 1,
method: serde_json::from_str(&get!(x->6(String))).unwrap(),
source: serde_json::from_str(&get!(x->7(String))).unwrap(),
}
}
auto_method!(get_transfer_by_id(usize as i64)@get_transfer_from_row -> "SELECT * FROM transfers WHERE id = $1" --name="transfer" --returns=CoinTransfer --cache-key-tmpl="atto.transfer:{}");
/// Fill a list of transfers with their users and product.
pub async fn fill_transfers(
&self,
list: Vec<CoinTransfer>,
) -> Result<Vec<(User, User, Option<Product>, CoinTransfer)>> {
let mut out = Vec::new();
let mut seen_users: HashMap<usize, User> = HashMap::new();
let mut seen_products: HashMap<usize, Product> = HashMap::new();
for transfer in list {
out.push((
if let Some(user) = seen_users.get(&transfer.sender) {
user.to_owned()
} else {
let user = self.get_user_by_id(transfer.sender).await?;
seen_users.insert(user.id, user.clone());
user
},
if let Some(user) = seen_users.get(&transfer.receiver) {
user.to_owned()
} else {
let user = self.get_user_by_id(transfer.receiver).await?;
seen_users.insert(user.id, user.clone());
user
},
match transfer.method {
CoinTransferMethod::Transfer => None,
CoinTransferMethod::Purchase(id) => {
if let Some(product) = seen_products.get(&id) {
Some(product.to_owned())
} else {
match self.get_product_by_id(id).await {
Ok(product) => {
seen_products.insert(product.id, product.clone());
Some(product)
}
Err(_) => None,
}
}
}
},
transfer,
));
}
Ok(out)
}
/// Get all transfers by user.
///
/// # Arguments
/// * `id` - the ID of the user to fetch transfers for
/// * `batch` - the limit of items in each page
/// * `page` - the page number
pub async fn get_transfers_by_user(
&self,
id: usize,
batch: usize,
page: usize,
) -> Result<Vec<CoinTransfer>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM transfers WHERE sender = $1 OR receiver = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|x| { Self::get_transfer_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("transfer".to_string()));
}
Ok(res.unwrap())
}
/// Get a transfer by user and method.
///
/// # Arguments
/// * `id` - the ID of the user to fetch transfers for
/// * `method` - the transfer method
pub async fn get_transfer_by_sender_method(
&self,
id: usize,
method: CoinTransferMethod,
) -> Result<CoinTransfer> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(
&conn,
"SELECT * FROM transfers WHERE sender = $1 AND method = $2 LIMIT 1",
params![&(id as i64), &serde_json::to_string(&method).unwrap()],
|x| { Ok(Self::get_transfer_from_row(x)) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("transfer".to_string()));
}
Ok(res.unwrap())
}
/// Create a new transfer in the database.
///
/// # Arguments
/// * `data` - a mock [`CoinTransfer`] object to insert
pub async fn create_transfer(&self, data: &mut CoinTransfer, apply: bool) -> Result<usize> {
// check values
let mut sender = self.get_user_by_id(data.sender).await?;
let mut receiver = self.get_user_by_id(data.receiver).await?;
if sender.id == self.0.0.system_user {
// system user can create coins from the void
sender.coins = i32::MAX;
}
let (sender_bankrupt, receiver_bankrupt) = data.apply(&mut sender, &mut receiver);
if sender_bankrupt | receiver_bankrupt {
return Err(Error::MiscError(
"One party of this transfer cannot afford this".to_string(),
));
}
if apply {
if sender.id != self.0.0.system_user {
self.update_user_coins(sender.id, sender.coins).await?;
}
self.update_user_coins(receiver.id, receiver.coins).await?;
// handle refund notification
if data.source == CoinTransferSource::Refund {
self.create_notification(Notification::new(
"A coin refund has been issued to your account!".to_string(),
"You've been issued a refund for a prior purchase. The product will remain in your account, but your coins have been returned.".to_string(),
receiver.id,
))
.await?;
}
} else {
// we haven't applied the transfer, so this must be pending
data.is_pending = true;
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"INSERT INTO transfers VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
params![
&(data.id as i64),
&(data.created as i64),
&(data.sender as i64),
&(data.receiver as i64),
&data.amount,
&{ if data.is_pending { 1 } else { 0 } },
&serde_json::to_string(&data.method).unwrap(),
&serde_json::to_string(&data.source).unwrap(),
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(data.id)
}
/// Apply a pending transfer.
pub async fn apply_transfer(&self, id: usize) -> Result<()> {
let transfer = self.get_transfer_by_id(id).await?;
let mut sender = self.get_user_by_id(transfer.sender).await?;
let mut receiver = self.get_user_by_id(transfer.receiver).await?;
let (sender_bankrupt, receiver_bankrupt) = transfer.apply(&mut sender, &mut receiver);
if sender_bankrupt | receiver_bankrupt {
return Err(Error::MiscError(
"One party of this transfer cannot afford this".to_string(),
));
}
self.update_user_coins(sender.id, sender.coins).await?;
self.update_user_coins(receiver.id, receiver.coins).await?;
self.update_transfer_is_pending(id, 0).await?;
self.create_notification(Notification::new(
"Purchase fulfilled!".to_string(),
format!(
"You've just successfully fulfilled a purchase for a [product](/product/{}).",
match transfer.method {
CoinTransferMethod::Purchase(x) => x,
_ => 0,
}
),
receiver.id,
))
.await?;
self.create_notification(Notification::new(
"Purchase fulfilled!".to_string(),
format!(
"Your purchase for a [product](/product/{}) has been fulfilled.",
match transfer.method {
CoinTransferMethod::Purchase(x) => x,
_ => 0,
}
),
sender.id,
))
.await?;
Ok(())
}
auto_method!(update_transfer_is_pending(i32) -> "UPDATE transfers SET is_pending = $1 WHERE id = $2" --cache-key-tmpl="atto.transfer:{}");
}

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