diff --git a/Cargo.toml b/Cargo.toml index b5beca0..c25b56f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 29ff4e6..fcc76f0 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -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"); @@ -152,6 +153,8 @@ 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"); @@ -390,6 +393,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { 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 } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 73507f4..e06e6ed 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -208,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" @@ -337,9 +338,22 @@ version = "1.0.0" "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" diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 686cd49..1a53a2e 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -201,8 +201,7 @@ p { .name { max-width: 250px; overflow: hidden; - /* overflow-wrap: break-word; */ - overflow-wrap: anywhere; + white-space: nowrap; text-overflow: ellipsis; } diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index a3145db..52f3464 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -1,5 +1,10 @@ @import url("root.css"); +/* ads */ +.tetratto_ad iframe { + border-radius: var(--radius); +} + /* media gallery */ .media_gallery { display: grid; @@ -12,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; } } @@ -556,6 +563,10 @@ input[type="checkbox"]:checked { background-image: url("/icons/check.svg"); } +label { + cursor: pointer; +} + /* pillmenu */ .pillmenu { display: flex; diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 705b14c..599106e 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -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(); diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 454a868..62a1c00 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -182,7 +182,7 @@ (text "{%- endif %} {%- endif %}") (button ("class" "primary") - (text "{{ text \"communities:action.create\" }}")))))) + (str (text "communities:action.create"))))))) (text "{% if not quoting -%}") (script (text "async function create_post_from_form(e) { diff --git a/crates/app/src/public/html/communities/list.lisp b/crates/app/src/public/html/communities/list.lisp index bb243c8..e65cd8a 100644 --- a/crates/app/src/public/html/communities/list.lisp +++ b/crates/app/src/public/html/communities/list.lisp @@ -39,7 +39,7 @@ (span (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") diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index d55a324..622b569 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -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") @@ -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") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index f88a872..c797ea8 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -87,13 +87,13 @@ (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 flex gap_1") - ("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 @@ -107,7 +107,7 @@ (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)") @@ -119,13 +119,19 @@ ("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") @@ -378,7 +384,7 @@ (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 "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}")) @@ -459,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 @@ -617,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") @@ -888,7 +894,7 @@ (div ("class" "flex gap_2") (button - (text "{{ text \"communities:action.create\" }}")) + (str (text "communities:action.create"))) (text "{% if drawing_enabled -%}") (button @@ -1344,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") @@ -2485,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 @@ -2701,3 +2714,33 @@ (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 %}") diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index a5f31e9..21dc65e 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -54,7 +54,7 @@ ("placeholder" "redirect URL") ("minlength" "2"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) ; app listing (div diff --git a/crates/app/src/public/html/economy/ad.lisp b/crates/app/src/public/html/economy/ad.lisp new file mode 100644 index 0000000..fe4581f --- /dev/null +++ b/crates/app/src/public/html/economy/ad.lisp @@ -0,0 +1,63 @@ +(text "") +(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%; + }")))) diff --git a/crates/app/src/public/html/economy/edit.lisp b/crates/app/src/public/html/economy/edit.lisp index 30accd2..28e5db5 100644 --- a/crates/app/src/public/html/economy/edit.lisp +++ b/crates/app/src/public/html/economy/edit.lisp @@ -4,6 +4,23 @@ (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 @@ -77,10 +94,22 @@ ("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" "title") + ("for" "price") (str (text "economy:label.price"))) (input ("type" "number") @@ -118,7 +147,7 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "title") + ("for" "stock") (str (text "economy:label.stock"))) (input ("type" "number") @@ -138,44 +167,158 @@ (icon (text "package-check")) (b (str (text "economy:label.fulfillment_style")))) - (form + (div ("class" "card flex flex_col gap_2") - ("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.")) + (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 product.method != \"ManualMail\" -%} 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 product.method != \"ManualMail\" -%} {{ product.method.AutoMail }} {%- endif %}"))) - (button (str (text "general:action.save"))))) + ("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")))))) - (a - ("class" "button secondary") - ("href" "/product/{{ product.id }}") - (icon (text "arrow-left")) - (str (text "general:action.back")))) + (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 update_title_from_form(e) { + (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\"]); @@ -240,6 +383,27 @@ }); } + 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\"]); @@ -306,6 +470,49 @@ }); } + 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; @@ -316,7 +523,46 @@ } } + 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 %}") diff --git a/crates/app/src/public/html/economy/edit_ad.lisp b/crates/app/src/public/html/economy/edit_ad.lisp new file mode 100644 index 0000000..088a91a --- /dev/null +++ b/crates/app/src/public/html/economy/edit_ad.lisp @@ -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 %}") diff --git a/crates/app/src/public/html/economy/product.lisp b/crates/app/src/public/html/economy/product.lisp index bcceb2b..b59e4fb 100644 --- a/crates/app/src/public/html/economy/product.lisp +++ b/crates/app/src/public/html/economy/product.lisp @@ -4,6 +4,7 @@ (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 @@ -19,21 +20,46 @@ ("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 }}")) - (text "{% if user.id != product.owner -%}") + ; 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") @@ -62,6 +88,59 @@ 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 %}") diff --git a/crates/app/src/public/html/economy/products.lisp b/crates/app/src/public/html/economy/products.lisp index c6f734a..fcd69f0 100644 --- a/crates/app/src/public/html/economy/products.lisp +++ b/crates/app/src/public/html/economy/products.lisp @@ -43,7 +43,7 @@ ("minlength" "2") ("maxlength" "1024"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) ; product listing (div @@ -56,7 +56,105 @@ (div ("class" "card flex flex_col gap_2") (text "{% for item in list %} {{ components::product_listing_card(product=item, edit=true) }} {% endfor %}") - (text "{{ components::pagination(page=page, items=list|length) }}")))) + ; 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 "<script src=\"{{ config.host }}\"/js/ads.js\"></script> +<script>TetrattoAds.init(); TetrattoAds.render_ads(\"{{ user.id }}\", \"{{ config.host }}\")</script>"))) + (p (text "After you've done that, you can place your ads like so:")) + (pre (code (text "<object class=\"tetratto_ad\" data-ad-size=\"$size$\"></object>"))) + (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) { @@ -87,5 +185,43 @@ }, 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 %}") diff --git a/crates/app/src/public/html/economy/wallet.lisp b/crates/app/src/public/html/economy/wallet.lisp index 0fb5b92..0981f09 100644 --- a/crates/app/src/public/html/economy/wallet.lisp +++ b/crates/app/src/public/html/economy/wallet.lisp @@ -11,7 +11,13 @@ (span ("class" "flex items_center gap_2") (icon (text "piggy-bank")) - (span (str (text "general:link.wallet"))))) + (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 @@ -32,7 +38,7 @@ (icon (text "clock")) (span (str (text "economy:label.recent_transfers"))))) (div - ("class" "card lowered flex flex_col gap_4") + ("class" "card flex flex_col gap_4") (div ("class" "w_full") ("style" "overflow: auto") @@ -43,26 +49,39 @@ (th (text "Sender")) (th (text "Receiver")) (th (text "Amount")) - (th (text "Product"))) + (th (text "Product")) + (th (text "Source")) + (th (text "Actions"))) (tbody (text "{% for transfer in list -%}") (tr - (td (span ("class" "date short") (text "{{ transfer[1] }}"))) - (td ("class" "w_content") (text "{{ components::full_username(user=transfer[3]) }}")) - (td ("class" "w_content") (text "{{ components::full_username(user=transfer[4]) }}")) + (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[2] }}") - (text "{% if transfer[6] -%}") + (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[5] -%}") + (text "{% if transfer[2] -%}") (a - ("href" "/product/{{ transfer[5].id }}") + ("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 "{%- endfor %}")))) + (text "{{ components::pagination(page=page, items=list|length) }}")))) (dialog ("id" "buy_dialog") @@ -112,5 +131,23 @@ 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 %}") diff --git a/crates/app/src/public/html/forge/home.lisp b/crates/app/src/public/html/forge/home.lisp index a25cf5d..56f1d01 100644 --- a/crates/app/src/public/html/forge/home.lisp +++ b/crates/app/src/public/html/forge/home.lisp @@ -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 %}") diff --git a/crates/app/src/public/html/littleweb/domains.lisp b/crates/app/src/public/html/littleweb/domains.lisp index b39c716..c0a3779 100644 --- a/crates/app/src/public/html/littleweb/domains.lisp +++ b/crates/app/src/public/html/littleweb/domains.lisp @@ -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 diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index 1b7eea0..1325780 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -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") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index ab7c3af..cadeeba 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -79,6 +79,7 @@ ("href" "/mail") (icon (text "mail")) (str (text "general:link.mail"))) + (text "{% if config.stripe -%}") (a ("href" "/wallet") (icon (text "piggy-bank")) @@ -87,6 +88,7 @@ ("href" "/products") (icon (text "store")) (str (text "economy:label.my_products"))) + (text "{%- endif %}") (a ("href" "/journals/0/0") (icon (text "notebook")) diff --git a/crates/app/src/public/html/mod/file_report.lisp b/crates/app/src/public/html/mod/file_report.lisp index f1748c0..154583f 100644 --- a/crates/app/src/public/html/mod/file_report.lisp +++ b/crates/app/src/public/html/mod/file_report.lisp @@ -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) { diff --git a/crates/app/src/public/html/mod/ip_bans.lisp b/crates/app/src/public/html/mod/ip_bans.lisp index c612cf9..d48b3cf 100644 --- a/crates/app/src/public/html/mod/ip_bans.lisp +++ b/crates/app/src/public/html/mod/ip_bans.lisp @@ -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 %}") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 8dcf616..7e12f8c 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -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 diff --git a/crates/app/src/public/html/mod/warnings.lisp b/crates/app/src/public/html/mod/warnings.lisp index c5b783a..d84ab53 100644 --- a/crates/app/src/public/html/mod/warnings.lisp +++ b/crates/app/src/public/html/mod/warnings.lisp @@ -37,7 +37,7 @@ ("minlength" "2") ("maxlength" "4096"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) (div ("class" "card_nest") (div diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 1eae1ab..e53ca2e 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -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") diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index c1788b3..1fde607 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -304,7 +304,7 @@ globalThis.request_transfer = async () => { await trigger(\"atto::debounce\", [\"economy::transfer\"]); - const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"\"); + const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"0\"); if (amount === 0) { return; @@ -468,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(() => { diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 0356283..8bda6c2 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -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\"], [ [ diff --git a/crates/app/src/public/html/stacks/list.lisp b/crates/app/src/public/html/stacks/list.lisp index dbcf944..27714ce 100644 --- a/crates/app/src/public/html/stacks/list.lisp +++ b/crates/app/src/public/html/stacks/list.lisp @@ -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") diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index b3235b9..abf3ede 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -4,7 +4,9 @@ (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex_col gap_2") - (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", forum_posts=\"/all/forum_posts\") }} {% 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 diff --git a/crates/app/src/public/js/ads.js b/crates/app/src/public/js/ads.js new file mode 100644 index 0000000..654ae72 --- /dev/null +++ b/crates/app/src/public/js/ads.js @@ -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); + } +}; diff --git a/crates/app/src/routes/api/v1/ads.rs b/crates/app/src/routes/api/v1/ads.rs new file mode 100644 index 0000000..15c80bd --- /dev/null +++ b/crates/app/src/routes/api/v1/ads.rs @@ -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, + JsonMultipart(bytes_parts, req): JsonMultipart, +) -> 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, + Path(id): Path, +) -> 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, + Path(id): Path, + Json(req): Json, +) -> 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, + 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), + } +} diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 58aa1e8..14ca49a 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -7,7 +7,7 @@ use axum::{ }; use tetratto_core::model::{ auth::{Notification, User}, - economy::{CoinTransfer, CoinTransferMethod}, + economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource}, moderation::AuditLogEntry, permissions::{FinePermission, SecondaryPermission}, ApiReturn, Error, @@ -635,6 +635,7 @@ pub async fn handle_stupid_fucking_checkout_success_session( user.id, 100, CoinTransferMethod::Transfer, + CoinTransferSource::Purchase, ), true, ) @@ -651,6 +652,7 @@ pub async fn handle_stupid_fucking_checkout_success_session( user.id, 400, CoinTransferMethod::Transfer, + CoinTransferSource::Purchase, ), true, ) diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index ba3c17a..5323b33 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -3,10 +3,11 @@ use crate::{ get_user_from_token, model::{ApiReturn, Error}, routes::api::v1::{ - AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, - UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanExpire, - 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, + Extension(data): Extension, + Json(req): Json, +) -> 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, + Extension(data): Extension, + Json(req): Json, +) -> 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, Json(req): Json, @@ -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, + 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. diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 3a3b081..46c1bdb 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -1,3 +1,4 @@ +pub mod ads; pub mod app_data; pub mod apps; pub mod auth; @@ -25,13 +26,13 @@ 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, + economy::{ProductFulfillmentMethod, UserAdSize}, journals::JournalPrivacyPermission, littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, @@ -333,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), @@ -389,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), @@ -396,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( @@ -719,6 +732,10 @@ pub fn routes() -> Router { // 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)) @@ -728,16 +745,34 @@ pub fn routes() -> Router { "/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 { @@ -1270,11 +1305,21 @@ 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, @@ -1289,3 +1334,41 @@ pub struct UpdateProductMethod { 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, +} diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index e532d01..2975bbe 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -1,9 +1,21 @@ -use crate::{get_user_from_token, State, cookie::CookieJar}; +use crate::{ + cookie::CookieJar, + get_user_from_token, + image::{save_webp_buffer, JsonMultipart}, + State, +}; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use tetratto_core::model::{economy::Product, oauth, ApiReturn, Error}; +use tetratto_core::model::{ + economy::{Product, ProductFulfillmentMethod}, + oauth, + permissions::FinePermission, + uploads::{MediaType, MediaUpload}, + ApiReturn, Error, +}; use super::{ - CreateProduct, UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, - UpdateProductPrice, UpdateProductStock, UpdateProductTitle, + CreateProduct, ProductUploadTarget, RemoveProductThumbnail, UpdateProductData, + UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, UpdateProductPrice, + UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, UpdateProductUploads, }; pub async fn create_request( @@ -36,7 +48,7 @@ pub async fn delete_request( Path(id): Path, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; @@ -112,6 +124,33 @@ pub async fn update_description_request( } } +pub async fn update_data_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(mut req): Json, +) -> 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, @@ -137,6 +176,31 @@ pub async fn update_on_sale_request( } } +pub async fn update_single_use_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> 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, @@ -149,7 +213,14 @@ pub async fn update_price_request( None => return Json(Error::NotAllowed.into()), }; - if req.price < 25 { + 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(), @@ -180,6 +251,24 @@ pub async fn update_method_request( 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, @@ -232,3 +321,163 @@ pub async fn buy_request( 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, + Path(id): Path, + JsonMultipart(bytes_parts, req): JsonMultipart, +) -> 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, + Path(id): Path, + Json(req): Json, +) -> 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()), + } +} diff --git a/crates/app/src/routes/api/v1/transfers.rs b/crates/app/src/routes/api/v1/transfers.rs index be26656..d3a1346 100644 --- a/crates/app/src/routes/api/v1/transfers.rs +++ b/crates/app/src/routes/api/v1/transfers.rs @@ -1,7 +1,7 @@ use crate::{get_user_from_token, State, cookie::CookieJar}; -use axum::{response::IntoResponse, Extension, Json}; +use axum::{response::IntoResponse, Extension, Json, extract::Path}; use tetratto_core::model::{ - economy::{CoinTransfer, CoinTransferMethod}, + economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource}, oauth, requests::{ActionData, ActionRequest, ActionType}, ApiReturn, Error, @@ -29,6 +29,7 @@ pub async fn create_request( }, req.amount, CoinTransferMethod::Transfer, // this endpoint is ONLY for regular transfers; products export a buy endpoint for the other method + CoinTransferSource::General, ), true, ) @@ -74,3 +75,46 @@ pub async fn ask_request( Err(e) => Json(e.into()), } } + +pub async fn create_refund_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> 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()), + } +} diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index a1d11f8..0e1d807 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -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", ]))), )); } diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index f18ede0..7f220eb 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -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")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index cde54f5..f9a6df9 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -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)), diff --git a/crates/app/src/routes/pages/economy.rs b/crates/app/src/routes/pages/economy.rs index 36c9cbe..97fc264 100644 --- a/crates/app/src/routes/pages/economy.rs +++ b/crates/app/src/routes/pages/economy.rs @@ -4,9 +4,13 @@ use axum::{ Extension, }; use crate::cookie::CookieJar; -use tetratto_core::model::Error; +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( @@ -60,7 +64,36 @@ pub async fn products_request( } }; - let list = match data.0.get_products_by_user(user.id, 12, props.page).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)), }; @@ -69,7 +102,9 @@ pub async fn products_request( 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( @@ -138,14 +173,154 @@ pub async fn product_request( 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 = + 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, + Path(id): Path, +) -> 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, + Query(props): Query, +) -> 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, + Query(props): Query, + Path(id): Path, +) -> 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()), + ) +} diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index a4d6ad5..16011cc 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -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; @@ -685,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, - Query(req): Query, -) -> 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, + req: &TimelineQuery, + jar: &CookieJar, +) -> std::result::Result, Html> { let ignore_users = crate::ignore_users_gen!(user, data); let list = if req.stack_id != 0 { @@ -807,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, + Query(mut req): Query, +) -> 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; diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index be8c3df..e147c1b 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -166,6 +166,9 @@ pub fn routes() -> Router { .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 { @@ -188,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, } diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 3f58805..dc6eca1 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -232,6 +232,7 @@ pub fn profile_context( user: &Option, profile: &User, communities: &Vec, + applied_configurations: Vec, 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", @@ -376,6 +378,10 @@ pub async fn posts_request( &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, @@ -492,6 +498,10 @@ pub async fn replies_request( &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, @@ -604,6 +614,10 @@ pub async fn media_request( &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, @@ -690,9 +704,13 @@ pub async fn shop_request( context.insert("page", &props.page); 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, @@ -784,9 +802,13 @@ pub async fn outbox_request( context.insert("page", &props.page); 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, @@ -896,6 +918,10 @@ pub async fn following_request( &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, @@ -1005,6 +1031,10 @@ pub async fn followers_request( &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, diff --git a/crates/app/src/sanitize.rs b/crates/app/src/sanitize.rs index fc992b3..1163c68 100644 --- a/crates/app/src/sanitize.rs +++ b/crates/app/src/sanitize.rs @@ -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://"), ) } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 30a1928..b07df61 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -344,6 +344,9 @@ pub struct Config { /// A list of banned content in posts. #[serde(default)] pub banned_data: Vec, + /// If user ads are enabled. + #[serde(default)] + pub enable_user_ads: bool, } fn default_name() -> String { @@ -463,6 +466,7 @@ impl Default for Config { stripe: None, manuals: default_manuals(), banned_data: default_banned_data(), + enable_user_ads: false, } } } diff --git a/crates/core/src/database/ads.rs b/crates/core/src/database/ads.rs new file mode 100644 index 0000000..cb6463a --- /dev/null +++ b/crates/core/src/database/ads.rs @@ -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> { + 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> { + 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 { + // 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 { + 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 { + 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) -> Result { + 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:{}"); +} diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 1a37e3a..21bba30 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,15 +1,15 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; -use crate::model::auth::{ - Achievement, AchievementName, AchievementRarity, Notification, 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}; @@ -130,6 +130,7 @@ impl DataManager { 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(), } } @@ -286,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, $32, $33)", + "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), @@ -321,6 +322,7 @@ impl DataManager { &(data.ban_expire as i64), &(data.coins as i32), &serde_json::to_string(&data.checkouts).unwrap(), + &serde_json::to_string(&data.applied_configurations).unwrap(), ] ); @@ -538,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?; @@ -1036,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> { + 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!( + "", + owner.username + )); + + continue; + } + + out.push(match config.0 { + AppliedConfigType::StyleSnippet => { + format!( + "", + product.data.replace("<", "<").replace(">", ">") + ) + } + }) + } + + Ok(out) + } + pub async fn cache_clear_user(&self, user: &User) { self.0.1.remove(format!("atto.user:{}", user.id)).await; self.0 @@ -1064,6 +1160,7 @@ impl DataManager { 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)@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); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 4d3fe55..5a4cf82 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -46,6 +46,7 @@ impl DataManager { 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(); diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 7c1b2e5..ca8fa79 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -34,3 +34,4 @@ 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"); diff --git a/crates/core/src/database/drivers/sql/create_ads.sql b/crates/core/src/database/drivers/sql/create_ads.sql new file mode 100644 index 0000000..1c7fbf6 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_ads.sql @@ -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 +) diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql index be32f3c..2f27dcd 100644 --- a/crates/core/src/database/drivers/sql/create_products.sql +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -7,5 +7,8 @@ CREATE TABLE IF NOT EXISTS products ( method TEXT NOT NULL, on_sale INT NOT NULL, price INT NOT NULL, - stock INT NOT NULL + stock INT NOT NULL, + single_use INT NOT NULL, + data TEXT NOT NULL, + uploads TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_transfers.sql b/crates/core/src/database/drivers/sql/create_transfers.sql index d747c78..ea157e4 100644 --- a/crates/core/src/database/drivers/sql/create_transfers.sql +++ b/crates/core/src/database/drivers/sql/create_transfers.sql @@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS transfers ( receiver BIGINT NOT NULL, amount INT NOT NULL, is_pending INT NOT NULL, - method TEXT NOT NULL + method TEXT NOT NULL, + source TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index d86bcbe..8ec2c22 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -31,5 +31,6 @@ CREATE TABLE IF NOT EXISTS users ( is_deactivated INT NOT NULL, ban_expire BIGINT NOT NULL, coins INT NOT NULL, - checkouts TEXT NOT NULL + checkouts TEXT NOT NULL, + applied_configurations TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index a935589..fb70e6b 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -45,3 +45,23 @@ 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 '{}'; diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index c52a107..81d0c83 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,3 +1,4 @@ +mod ads; pub mod app_data; mod apps; mod audit_log; @@ -39,3 +40,4 @@ mod userfollows; pub use drivers::DataManager; pub use common::NAME_REGEX; +pub use posts::FullPost; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 7a7a2a7..35448de 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -2221,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 diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 714531d..db8fce9 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -1,6 +1,9 @@ use crate::model::{ auth::User, - economy::{CoinTransfer, CoinTransferMethod, Product, ProductFulfillmentMethod}, + economy::{ + CoinTransfer, CoinTransferMethod, CoinTransferSource, Product, ProductFulfillmentMethod, + ProductUploads, + }, mail::Letter, permissions::FinePermission, Error, Result, @@ -21,6 +24,9 @@ impl DataManager { 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(), } } @@ -57,7 +63,7 @@ impl DataManager { Ok(res.unwrap()) } - const MAXIMUM_FREE_PRODUCTS: usize = 5; + const MAXIMUM_FREE_PRODUCTS: usize = 10; /// Create a new product in the database. /// @@ -103,7 +109,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "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), @@ -113,7 +119,10 @@ impl DataManager { &serde_json::to_string(&data.method).unwrap(), &{ if data.on_sale { 1 } else { 0 } }, &data.price, - &(data.stock as i32) + &(data.stock as i32), + &{ if data.single_use { 1 } else { 0 } }, + &data.data, + &serde_json::to_string(&data.uploads).unwrap(), ] ); @@ -131,11 +140,28 @@ impl DataManager { customer: &mut User, ) -> Result { 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() { @@ -198,6 +224,21 @@ If your product is a purchase of goods or services, please be sure to fulfill th // 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) + } } } @@ -209,6 +250,15 @@ If your product is a purchase of goods or services, please be sure to fulfill th 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, @@ -231,6 +281,9 @@ If your product is a purchase of goods or services, please be sure to fulfill th 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); diff --git a/crates/core/src/database/requests.rs b/crates/core/src/database/requests.rs index 84356ac..aad24ab 100644 --- a/crates/core/src/database/requests.rs +++ b/crates/core/src/database/requests.rs @@ -29,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 { diff --git a/crates/core/src/database/transfers.rs b/crates/core/src/database/transfers.rs index 40507b8..4653166 100644 --- a/crates/core/src/database/transfers.rs +++ b/crates/core/src/database/transfers.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; use crate::model::{ - Error, Result, - economy::{CoinTransferMethod, Product, CoinTransfer}, auth::{Notification, User}, + economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, Product}, + Error, Result, }; use crate::{auto_method, DataManager}; -use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; +use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow}; impl DataManager { /// Get a [`CoinTransfer`] from an SQL row. @@ -18,6 +18,7 @@ impl DataManager { 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(), } } @@ -27,16 +28,13 @@ impl DataManager { pub async fn fill_transfers( &self, list: Vec, - ) -> Result, bool)>> { + ) -> Result, CoinTransfer)>> { let mut out = Vec::new(); let mut seen_users: HashMap = HashMap::new(); let mut seen_products: HashMap = HashMap::new(); for transfer in list { out.push(( - transfer.id, - transfer.created, - transfer.amount, if let Some(user) = seen_users.get(&transfer.sender) { user.to_owned() } else { @@ -54,16 +52,20 @@ impl DataManager { match transfer.method { CoinTransferMethod::Transfer => None, CoinTransferMethod::Purchase(id) => { - Some(if let Some(product) = seen_products.get(&id) { - product.to_owned() + if let Some(product) = seen_products.get(&id) { + Some(product.to_owned()) } else { - let product = self.get_product_by_id(id).await?; - seen_products.insert(product.id, product.clone()); - product - }) + match self.get_product_by_id(id).await { + Ok(product) => { + seen_products.insert(product.id, product.clone()); + Some(product) + } + Err(_) => None, + } + } } }, - transfer.is_pending, + transfer, )); } @@ -101,6 +103,35 @@ impl DataManager { 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 { + 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 @@ -129,6 +160,16 @@ impl DataManager { } 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; @@ -142,7 +183,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO transfers VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO transfers VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", params![ &(data.id as i64), &(data.created as i64), @@ -151,6 +192,7 @@ impl DataManager { &data.amount, &{ if data.is_pending { 1 } else { 0 } }, &serde_json::to_string(&data.method).unwrap(), + &serde_json::to_string(&data.source).unwrap(), ] ); diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index afd6aa1..902e97f 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -101,11 +101,20 @@ pub struct User { /// already applied this purchase. #[serde(default)] pub checkouts: Vec, + /// The IDs of products to be applied to the user's profile. + #[serde(default)] + pub applied_configurations: Vec<(AppliedConfigType, usize)>, } pub type UserConnections = HashMap; +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum AppliedConfigType { + /// An HTML `