add: user ads

This commit is contained in:
trisua 2025-08-11 20:21:05 -04:00
parent 46b3e66cd4
commit 2cb7d08ddc
41 changed files with 1095 additions and 29 deletions

View file

@ -38,6 +38,7 @@ pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
pub const CARP_JS: &str = include_str!("./public/js/carp.js"); pub const CARP_JS: &str = include_str!("./public/js/carp.js");
pub const PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.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 APP_SDK_JS: &str = include_str!("./public/js/app_sdk.js");
pub const ADS_JS: &str = include_str!("./public/js/ads.js");
// html // html
pub const BODY: &str = include_str!("./public/html/body.lisp"); 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_PRODUCTS: &str = include_str!("./public/html/economy/products.lisp");
pub const ECONOMY_EDIT: &str = include_str!("./public/html/economy/edit.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_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 // langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); 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/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/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/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 html_path
} }

View file

@ -350,3 +350,10 @@ version = "1.0.0"
"economy:action.apply" = "Apply" "economy:action.apply" = "Apply"
"economy:action.unapply" = "Unapply" "economy:action.unapply" = "Unapply"
"economy:label.thumbnails" = "Thumbnails" "economy:label.thumbnails" = "Thumbnails"
"economy:label.my_ads" = "My ads"
"economy:label.create_new_ad" = "Create new advertisement"
"economy:label.target" = "Target URL"
"economy:label.image" = "Image"
"economy:label.size_base" = "Size base"
"economy:label.running" = "Running"
"economy:label.embed_ads_on_my_site" = "Embed ads on my site"

View file

@ -1,5 +1,10 @@
@import url("root.css"); @import url("root.css");
/* ads */
.tetratto_ad iframe {
border-radius: var(--radius);
}
/* media gallery */ /* media gallery */
.media_gallery { .media_gallery {
display: grid; display: grid;

View file

@ -1,5 +1,10 @@
(div ("id" "toast_zone")) (div ("id" "toast_zone"))
; ads
(script ("src" "/js/ads.js?v=tetratto-{{ random_cache_breaker }}"))
(script
(text "TetrattoAds.init();"))
; large text ; large text
(text "{% if user and user.settings.large_text -%}") (text "{% if user and user.settings.large_text -%}")
(style (style
@ -76,6 +81,8 @@
return; return;
} }
TetrattoAds.render_ads(\"{{ config.system_user }}\", \"\");
atto.disconnect_observers(); atto.disconnect_observers();
atto.remove_false_options(); atto.remove_false_options();
atto.clean_date_codes(); atto.clean_date_codes();

View file

@ -182,7 +182,7 @@
(text "{%- endif %} {%- endif %}") (text "{%- endif %} {%- endif %}")
(button (button
("class" "primary") ("class" "primary")
(text "{{ text \"communities:action.create\" }}")))))) (str (text "communities:action.create")))))))
(text "{% if not quoting -%}") (text "{% if not quoting -%}")
(script (script
(text "async function create_post_from_form(e) { (text "async function create_post_from_form(e) {

View file

@ -39,7 +39,7 @@
(span (span
(text "Make this a forum community"))) (text "Make this a forum community")))
(button (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 %}") (text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}")
(div (div
("class" "card_nest w_full") ("class" "card_nest w_full")

View file

@ -302,7 +302,7 @@
("minlength" "2") ("minlength" "2")
("maxlength" "32"))) ("maxlength" "32")))
(button (button
(text "{{ text \"communities:action.create\" }}")))) (str (text "communities:action.create")))))
(text "{% for channel in channels %}") (text "{% for channel in channels %}")
(div (div
("class" "card_nest") ("class" "card_nest")
@ -472,7 +472,7 @@
("accept" "image/png,image/jpeg,image/avif,image/webp") ("accept" "image/png,image/jpeg,image/avif,image/webp")
("class" "w_full"))) ("class" "w_full")))
(button (button
(text "{{ text \"communities:action.create\" }}")) (str (text "communities:action.create")))
(span (span
("class" "fade") ("class" "fade")
(text "Emojis can be a maximum of 256 KiB.")))) (text "Emojis can be a maximum of 256 KiB."))))
@ -646,7 +646,7 @@
("min" "0") ("min" "0")
("max" "256"))) ("max" "256")))
(button (button
(text "{{ text \"communities:action.create\" }}")))) (str (text "communities:action.create")))))
(text "{% for id, topic in community.topics %}") (text "{% for id, topic in community.topics %}")
(div (div
("class" "card_nest") ("class" "card_nest")

View file

@ -894,7 +894,7 @@
(div (div
("class" "flex gap_2") ("class" "flex gap_2")
(button (button
(text "{{ text \"communities:action.create\" }}")) (str (text "communities:action.create")))
(text "{% if drawing_enabled -%}") (text "{% if drawing_enabled -%}")
(button (button
@ -2494,6 +2494,11 @@
(li (li
(text "Create and sell CSS snippet products")) (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 -%}") (text "{% if config.security.enable_invite_codes -%}")
(li (li
(text "Create up to 48 invite codes") (text "Create up to 48 invite codes")
@ -2709,3 +2714,33 @@
(icon (text "badge-cent")) (icon (text "badge-cent"))
(text "{{ product.price }}"))) (text "{{ product.price }}")))
(text "{%- endmacro %}") (text "{%- endmacro %}")
(text "{% macro ad_listing_card(ad) -%}")
(a
("class" "card button lowered w_full flex flex_col gap_2")
("href" "/product/ad/{{ ad.id }}/edit")
(b
("class" "flex gap_2 items_center")
("style" "height: 24px; text-decoration: {% if not ad.is_running -%} line-through {%- else -%} none {%- endif %}")
(icon (text "link"))
(text "{{ ad.target }}"))
(b
("style" "height: 18px")
(text "{% if ad.is_running -%}")
(span
("class" "green flex gap_2 items_center")
(icon (text "circle-check"))
(text "Running"))
(text "{% else %}")
(span
("class" "red flex gap_2 items_center")
(icon (text "circle-x"))
(text "Not running"))
(text "{%- endif %}")))
(text "{%- endmacro %}")
(text "{% macro advertisement(size=\"Leaderboard\") -%}")
(text "{% if not is_supporter and config.enable_user_ads -%}")
(object ("class" "tetratto_ad") ("data-ad-size" "{{ size }}"))
(text "{%- endif %}")
(text "{%- endmacro %}")

View file

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

View file

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

View file

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

View file

@ -43,7 +43,7 @@
("minlength" "2") ("minlength" "2")
("maxlength" "1024"))) ("maxlength" "1024")))
(button (button
(text "{{ text \"communities:action.create\" }}")))) (str (text "communities:action.create")))))
; product listing ; product listing
(div (div
@ -56,7 +56,105 @@
(div (div
("class" "card flex flex_col gap_2") ("class" "card flex flex_col gap_2")
(text "{% for item in list %} {{ components::product_listing_card(product=item, edit=true) }} {% endfor %}") (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 "&lt;script src=\"{{ config.host }}\"/js/ads.js\"&gt;&lt;/script&gt;
&lt;script&gt;TetrattoAds.init&lpar;&rpar;; TetrattoAds.render_ads&lpar;\"{{ user.id }}\", \"{{ config.host }}\"&rpar;&lt;/script&gt;")))
(p (text "After you've done that, you can place your ads like so:"))
(pre (code (text "&lt;object class=\"tetratto_ad\" data-ad-size=\"$size$\"&gt;&lt;/object&gt;")))
(p
(text "In the above example, replace \"$size$\" with a size from ")
(a ("href" "https://tetratto.com/reference/tetratto/model/economy/enum.UserAdSize.html") (text "here"))
(text " keep in mind that the name of a size must be in title case. That means it's \"Leaderboard\", not \"leaderboard\"."))))
(text "{%- endif %}"))
(script (script
(text "async function create_product_from_form(e) { (text "async function create_product_from_form(e) {
@ -87,5 +185,43 @@
}, 100); }, 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 %}") (text "{% endblock %}")

View file

@ -72,7 +72,7 @@
(text "{%- endif %}")) (text "{%- endif %}"))
(td (text "{{ transfer[3].source }}")) (td (text "{{ transfer[3].source }}"))
(td (td
(text "{% if user.id == transfer[1].id -%}") (text "{% if user.id == transfer[1].id and transfer[3].source == '\"Sale\"' -%}")
; we're the receiver ; we're the receiver
(button (button
("class" "small tiny square raised camo big_icon") ("class" "small tiny square raised camo big_icon")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -88,7 +88,7 @@
("class" "flex gap_2") ("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 %}") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
(button (button
(text "{{ text \"communities:action.create\" }}"))))) (str (text "communities:action.create"))))))
(text "{%- endif %}") (text "{%- endif %}")
(div (div
("class" "pillmenu") ("class" "pillmenu")

View file

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

View file

@ -4,7 +4,9 @@
(text "{% endblock %} {% block body %} {{ macros::nav() }}") (text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main (main
("class" "flex flex_col gap_2") ("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 (div
("class" "card_nest") ("class" "card_nest")
(div (div

View file

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

View file

@ -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<State>,
JsonMultipart(bytes_parts, req): JsonMultipart<CreateAd>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
// get file
let file = match bytes_parts.get(0) {
Some(x) => x,
None => return Json(Error::Unknown.into()),
};
if file.len() > MAXIMUM_AD_FILE_SIZE {
return Json(Error::FileTooLarge.into());
}
let upload = match data
.create_upload(MediaUpload::new(MediaType::Webp, user.id))
.await
{
Ok(x) => x,
Err(e) => return Json(e.into()),
};
match data
.create_ad(UserAd::new(user.id, upload.id, req.target, req.size))
.await
{
Ok(_) => {
// write image
if let Err(e) =
save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
{
return Json(Error::MiscError(e.to_string()).into());
}
// ...
Json(ApiReturn {
ok: true,
message: "Ad created".to_string(),
payload: (),
})
}
Err(e) => Json(e.into()),
}
}
pub async fn delete_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_ad(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Ad deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_is_running_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateAdIsRunning>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.update_ad_is_running(id, &user, if req.is_running { 1 } else { 0 })
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Ad updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn click_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((host, id)): Path<(usize, usize)>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = get_user_from_token!(jar, data);
match data.ad_click(host, id, user).await {
Ok(t) => Redirect::to(&t),
Err(_) => Redirect::to(&data.0.0.host),
}
}

View file

@ -1,3 +1,4 @@
pub mod ads;
pub mod app_data; pub mod app_data;
pub mod apps; pub mod apps;
pub mod auth; pub mod auth;
@ -31,7 +32,7 @@ use tetratto_core::model::{
PollOption, PostContext, PollOption, PostContext,
}, },
communities_permissions::CommunityPermission, communities_permissions::CommunityPermission,
economy::ProductFulfillmentMethod, economy::{ProductFulfillmentMethod, UserAdSize},
journals::JournalPrivacyPermission, journals::JournalPrivacyPermission,
littleweb::{DomainData, DomainTld, ServiceFsEntry}, littleweb::{DomainData, DomainTld, ServiceFsEntry},
oauth::AppScope, oauth::AppScope,
@ -767,6 +768,11 @@ pub fn routes() -> Router {
"/products/{id}/uploads/thumbnails", "/products/{id}/uploads/thumbnails",
delete(products::remove_thumbnail_request), 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 { pub fn lw_routes() -> Router {
@ -1355,3 +1361,14 @@ pub struct UpdateProductUploads {
pub struct RemoveProductThumbnail { pub struct RemoveProductThumbnail {
pub idx: usize, pub idx: usize,
} }
#[derive(Deserialize)]
pub struct CreateAd {
pub target: String,
pub size: UserAdSize,
}
#[derive(Deserialize)]
pub struct UpdateAdIsRunning {
pub is_running: bool,
}

View file

@ -48,7 +48,7 @@ pub async fn delete_request(
Path(id): Path<usize>, Path(id): Path<usize>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; 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, Some(ua) => ua,
None => return Json(Error::NotAllowed.into()), None => return Json(Error::NotAllowed.into()),
}; };

View file

@ -92,7 +92,7 @@ pub async fn create_refund_request(
Err(e) => return Json(e.into()), 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) // only the receiver of the funds can issue a refund (atm)
return Json(Error::NotAllowed.into()); return Json(Error::NotAllowed.into());
} }

View file

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

View file

@ -21,3 +21,4 @@ serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
serve_asset!(carp_js_request: CARP_JS("text/javascript")); serve_asset!(carp_js_request: CARP_JS("text/javascript"));
serve_asset!(proto_links_request: PROTO_LINKS_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!(app_sdk_request: APP_SDK_JS("text/javascript"));
serve_asset!(ads_request: ADS_JS("text/javascript"));

View file

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

View file

@ -4,9 +4,13 @@ use axum::{
Extension, Extension,
}; };
use crate::cookie::CookieJar; 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 crate::{assets::initial_context, get_lang, get_user_from_token, State};
use super::{render_error, PaginatedQuery}; use super::{render_error, PaginatedQuery};
use serde::Deserialize;
/// `/wallet` /// `/wallet`
pub async fn wallet_request( 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, Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
}; };
@ -69,7 +102,9 @@ pub async fn products_request(
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("list", &list); context.insert("list", &list);
context.insert("ads_list", &ads_list);
context.insert("page", &props.page); context.insert("page", &props.page);
context.insert("page_set_id", &props.page_set_id);
// return // return
Ok(Html( Ok(Html(
@ -166,3 +201,127 @@ pub async fn product_request(
data.1.render("economy/product.html", &context).unwrap(), data.1.render("economy/product.html", &context).unwrap(),
)) ))
} }
/// `/product/ad/{id}/edit`
pub async fn edit_ad_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let ad = match data.0.get_ad_by_id(id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if user.id != ad.owner {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("ad", &ad);
// return
Ok(Html(
data.1.render("economy/edit_ad.html", &context).unwrap(),
))
}
#[derive(Deserialize)]
pub struct RandomAdQuery {
pub host: usize,
#[serde(default)]
pub size: UserAdSize,
#[serde(default)]
pub noclick: bool,
}
/// `/adn/random`
pub async fn random_ad_request(
Extension(data): Extension<State>,
Query(props): Query<RandomAdQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let ad = match data.0.random_ad_charged(props.size.clone()).await {
Ok(x) => x,
Err(_) => UserAd {
// 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<State>,
Query(props): Query<RandomAdQuery>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = data.read().await;
let ad = match data.0.get_ad_by_id(id).await {
Ok(x) => x,
Err(_) => UserAd {
// polyfill ad
id: 0,
created: 0,
upload_id: 0,
owner: data.0.0.0.system_user,
target: data.0.0.0.host.clone(),
last_charge_time: 0,
is_running: true,
size: props.size,
},
};
let mut context = tera::Context::new();
context.insert("disable_click", &props.noclick);
context.insert("config", &data.0.0.0);
context.insert("host", &props.host);
context.insert("ad", &ad);
// return
(
[
(
"content-security-policy",
"default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors *",
),
("Cache-Control", "no-cache"),
],
Html(data.1.render("economy/ad.html", &context).unwrap()),
)
}

View file

@ -166,6 +166,9 @@ pub fn routes() -> Router {
.route("/products", get(economy::products_request)) .route("/products", get(economy::products_request))
.route("/product/{id}/edit", get(economy::edit_product_request)) .route("/product/{id}/edit", get(economy::edit_product_request))
.route("/product/{id}", get(economy::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 { pub fn lw_routes() -> Router {
@ -188,6 +191,11 @@ pub async fn render_error(
pub struct PaginatedQuery { pub struct PaginatedQuery {
#[serde(default)] #[serde(default)]
pub page: usize, 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)] #[serde(default)]
pub before: usize, pub before: usize,
} }

View file

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

View file

@ -344,6 +344,9 @@ pub struct Config {
/// A list of banned content in posts. /// A list of banned content in posts.
#[serde(default)] #[serde(default)]
pub banned_data: Vec<StringBan>, pub banned_data: Vec<StringBan>,
/// If user ads are enabled.
#[serde(default)]
pub enable_user_ads: bool,
} }
fn default_name() -> String { fn default_name() -> String {
@ -463,6 +466,7 @@ impl Default for Config {
stripe: None, stripe: None,
manuals: default_manuals(), manuals: default_manuals(),
banned_data: default_banned_data(), banned_data: default_banned_data(),
enable_user_ads: false,
} }
} }
} }

View file

@ -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<Vec<UserAd>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM ads WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|x| { Self::get_ad_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("ad".to_string()));
}
Ok(res.unwrap())
}
/// Create a new ad in the database.
///
/// # Arguments
/// * `data` - a mock [`UserAd`] object to insert
pub async fn create_ad(&self, data: UserAd) -> Result<UserAd> {
// check values
if data.target.len() < 2 {
return Err(Error::DataTooShort("description".to_string()));
} else if data.target.len() > 256 {
return Err(Error::DataTooLong("description".to_string()));
}
// charge for first day
if data.is_running {
self.create_transfer(
&mut CoinTransfer::new(
data.owner,
self.0.0.system_user,
Self::AD_RUN_CHARGE,
CoinTransferMethod::Transfer,
CoinTransferSource::AdCharge,
),
true,
)
.await?;
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"INSERT INTO ads VALUES (DEFAULT, $1, $2, $3, $4, $5, $6, $7)",
params![
&(data.created as i64),
&(data.owner as i64),
&(data.upload_id as i64),
&data.target,
&(data.last_charge_time as i64),
&if data.is_running { 1 } else { 0 },
&serde_json::to_string(&data.size).unwrap()
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(data)
}
pub async fn delete_ad(&self, id: usize, user: &User) -> Result<()> {
let ad = self.get_ad_by_id(id).await?;
// check user permission
if user.id != ad.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Err(Error::NotAllowed);
}
// remove upload
self.delete_upload(ad.upload_id).await?;
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(&conn, "DELETE FROM ads WHERE id = $1", &[&(id as i64)]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// ...
self.0.1.remove(format!("atto.ad:{}", id)).await;
Ok(())
}
/// Pull a random running ad.
pub async fn random_ad(&self, size: UserAdSize) -> Result<UserAd> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(
&conn,
"SELECT * FROM ads WHERE is_running = 1 AND size = $1 ORDER BY RANDOM() DESC LIMIT 1",
&[&serde_json::to_string(&size).unwrap()],
|x| { Ok(Self::get_ad_from_row(x)) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("ad".to_string()));
}
Ok(res.unwrap())
}
const MINIMUM_DELTA_FOR_CHARGE: usize = 604_800_000; // 7 days
/// The amount charged to a [`UserAd`] owner each day the ad is running (and is pulled from the pool).
pub const AD_RUN_CHARGE: i32 = 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<UserAd> {
let ad = self.random_ad(size).await?;
let now = unix_epoch_timestamp();
let delta = now - ad.last_charge_time;
if delta >= Self::MINIMUM_DELTA_FOR_CHARGE {
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<User>) -> Result<String> {
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:{}");
}

View file

@ -595,6 +595,13 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string())); 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 // delete user follows... individually since it requires updating user counts
for follow in self.get_userfollows_by_receiver_all(id).await? { for follow in self.get_userfollows_by_receiver_all(id).await? {
self.delete_userfollow(follow.id, &user, true).await?; self.delete_userfollow(follow.id, &user, true).await?;

View file

@ -46,6 +46,7 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap(); execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap();
execute!(&conn, common::CREATE_TABLE_TRANSFERS).unwrap(); execute!(&conn, common::CREATE_TABLE_TRANSFERS).unwrap();
execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap();
execute!(&conn, common::CREATE_TABLE_ADS).unwrap();
for x in common::VERSION_MIGRATIONS.split(";") { for x in common::VERSION_MIGRATIONS.split(";") {
execute!(&conn, x).unwrap(); execute!(&conn, x).unwrap();

View file

@ -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_LETTERS: &str = include_str!("./sql/create_letters.sql");
pub const CREATE_TABLE_TRANSFERS: &str = include_str!("./sql/create_transfers.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_PRODUCTS: &str = include_str!("./sql/create_products.sql");
pub const CREATE_TABLE_ADS: &str = include_str!("./sql/create_ads.sql");

View file

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

View file

@ -1,3 +1,4 @@
mod ads;
pub mod app_data; pub mod app_data;
mod apps; mod apps;
mod audit_log; mod audit_log;

View file

@ -104,6 +104,10 @@ pub enum CoinTransferSource {
Purchase, Purchase,
/// A refund of coins. /// A refund of coins.
Refund, Refund,
/// The charge for keeping an ad running.
AdCharge,
/// Gained coins from a click on an ad on your site.
AdClick,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -149,3 +153,69 @@ impl CoinTransfer {
(sender.coins < 0, receiver.coins < 0) (sender.coins < 0, receiver.coins < 0)
} }
} }
/// <https://en.wikipedia.org/wiki/Web_banner#Standard_sizes>
#[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,
}
}
}