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 c9e1772..e06e6ed 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -350,3 +350,10 @@ version = "1.0.0" "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/style.css b/crates/app/src/public/css/style.css index 1b14eb4..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; 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 1dfbc7f..622b569 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -302,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") @@ -472,7 +472,7 @@ ("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.")))) @@ -646,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 609732d..c797ea8 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -894,7 +894,7 @@ (div ("class" "flex gap_2") (button - (text "{{ text \"communities:action.create\" }}")) + (str (text "communities:action.create"))) (text "{% if drawing_enabled -%}") (button @@ -2494,6 +2494,11 @@ (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 (text "Create up to 48 invite codes") @@ -2709,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..8649284 --- /dev/null +++ b/crates/app/src/public/html/economy/ad.lisp @@ -0,0 +1,62 @@ +(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; + bottom: 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%); + }")))) 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/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 2ffd755..82cf02a 100644 --- a/crates/app/src/public/html/economy/wallet.lisp +++ b/crates/app/src/public/html/economy/wallet.lisp @@ -72,7 +72,7 @@ (text "{%- endif %}")) (td (text "{{ transfer[3].source }}")) (td - (text "{% if user.id == transfer[1].id -%}") + (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") 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/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/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/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..338b586 --- /dev/null +++ b/crates/app/src/routes/api/v1/ads.rs @@ -0,0 +1,132 @@ +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 = 4_194_304; + +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()), + }; + + 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/mod.rs b/crates/app/src/routes/api/v1/mod.rs index e6df9df..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; @@ -31,7 +32,7 @@ use tetratto_core::model::{ PollOption, PostContext, }, communities_permissions::CommunityPermission, - economy::ProductFulfillmentMethod, + economy::{ProductFulfillmentMethod, UserAdSize}, journals::JournalPrivacyPermission, littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, @@ -767,6 +768,11 @@ pub fn routes() -> Router { "/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 { @@ -1355,3 +1361,14 @@ pub struct UpdateProductUploads { 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 c9eef1a..2975bbe 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -48,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()), }; diff --git a/crates/app/src/routes/api/v1/transfers.rs b/crates/app/src/routes/api/v1/transfers.rs index 9547c50..d3a1346 100644 --- a/crates/app/src/routes/api/v1/transfers.rs +++ b/crates/app/src/routes/api/v1/transfers.rs @@ -92,7 +92,7 @@ pub async fn create_refund_request( Err(e) => return Json(e.into()), }; - if user.id != other_transfer.receiver { + 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()); } 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 63aa300..6d093ce 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::{economy::CoinTransferMethod, 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( @@ -166,3 +201,127 @@ pub async fn product_request( 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 { + // 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 *", + )], + 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/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/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..ce7ff91 --- /dev/null +++ b/crates/core/src/database/ads.rs @@ -0,0 +1,233 @@ +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()) + } + + /// 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 = 50; + /// 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 { + self.create_transfer( + &mut CoinTransfer::new( + ad.owner, + self.0.0.system_user, + Self::AD_RUN_CHARGE, + CoinTransferMethod::Transfer, + CoinTransferSource::AdCharge, + ), + true, + ) + .await?; + + 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 == ad.owner || 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 OR click on your own ads! This incident has been reported.".to_string() + ) + ).await?; + + return Ok(ad.target); + } + } + + // create click transfer + self.create_transfer( + &mut CoinTransfer::new( + ad.owner, + host, + Self::AD_CLICK_CHARGE, + CoinTransferMethod::Transfer, + CoinTransferSource::AdClick, + ), + true, + ) + .await?; + + // 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 ce183a4..21bba30 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -595,6 +595,13 @@ impl DataManager { 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?; 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/mod.rs b/crates/core/src/database/mod.rs index 18182cf..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; diff --git a/crates/core/src/model/economy.rs b/crates/core/src/model/economy.rs index 20ee0b2..6a94f9d 100644 --- a/crates/core/src/model/economy.rs +++ b/crates/core/src/model/economy.rs @@ -104,6 +104,10 @@ pub enum CoinTransferSource { Purchase, /// A refund of coins. Refund, + /// The charge for keeping an ad running. + AdCharge, + /// Gained coins from a click on an ad on your site. + AdClick, } #[derive(Serialize, Deserialize)] @@ -149,3 +153,69 @@ impl CoinTransfer { (sender.coins < 0, receiver.coins < 0) } } + +/// +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum UserAdSize { + /// 970x250 + Billboard, + /// 720x90 + Leaderboard, + /// 160x600 + Skyscraper, + /// 300x250 + MediumRectangle, + /// 320x50 - mobile only + MobileLeaderboard, +} + +impl Default for UserAdSize { + fn default() -> Self { + Self::MediumRectangle + } +} + +impl UserAdSize { + /// Get the dimensions of the size in CSS pixels. + pub fn dimensions(&self) -> (u16, u16) { + match self { + Self::Billboard => (970, 250), + Self::Leaderboard => (720, 90), + Self::Skyscraper => (160, 600), + Self::MediumRectangle => (300, 250), + Self::MobileLeaderboard => (320, 50), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct UserAd { + pub id: usize, + pub created: usize, + pub owner: usize, + pub upload_id: usize, + pub target: String, + /// The time that the owner was last charged for keeping this ad up. + /// + /// Ads cost 50 coins per day of running. + pub last_charge_time: usize, + pub is_running: bool, + pub size: UserAdSize, +} + +impl UserAd { + /// Create a new [`UserAd`]. + pub fn new(owner: usize, upload_id: usize, target: String, size: UserAdSize) -> Self { + let created = unix_epoch_timestamp(); + Self { + id: 0, // will be overwritten by postgres + created, + owner, + upload_id, + target, + last_charge_time: 0, + is_running: false, + size, + } + } +}