add: user ads
This commit is contained in:
parent
46b3e66cd4
commit
2cb7d08ddc
41 changed files with 1095 additions and 29 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
@import url("root.css");
|
||||
|
||||
/* ads */
|
||||
.tetratto_ad iframe {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* media gallery */
|
||||
.media_gallery {
|
||||
display: grid;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 %}")
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
("placeholder" "redirect URL")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
|
||||
; app listing
|
||||
(div
|
||||
|
|
62
crates/app/src/public/html/economy/ad.lisp
Normal file
62
crates/app/src/public/html/economy/ad.lisp
Normal 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%);
|
||||
}"))))
|
97
crates/app/src/public/html/economy/edit_ad.lisp
Normal file
97
crates/app/src/public/html/economy/edit_ad.lisp
Normal 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 %}")
|
|
@ -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 %}")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 %}")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 %}")
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "4096")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
65
crates/app/src/public/js/ads.js
Normal file
65
crates/app/src/public/js/ads.js
Normal 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);
|
||||
}
|
||||
};
|
132
crates/app/src/routes/api/v1/ads.rs
Normal file
132
crates/app/src/routes/api/v1/ads.rs
Normal 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),
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@ pub async fn delete_request(
|
|||
Path(id): Path<usize>,
|
||||
) -> 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()),
|
||||
};
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
]))),
|
||||
));
|
||||
}
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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<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()),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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://"),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -344,6 +344,9 @@ pub struct Config {
|
|||
/// A list of banned content in posts.
|
||||
#[serde(default)]
|
||||
pub banned_data: Vec<StringBan>,
|
||||
/// If user ads are enabled.
|
||||
#[serde(default)]
|
||||
pub enable_user_ads: bool,
|
||||
}
|
||||
|
||||
fn default_name() -> String {
|
||||
|
@ -463,6 +466,7 @@ impl Default for Config {
|
|||
stripe: None,
|
||||
manuals: default_manuals(),
|
||||
banned_data: default_banned_data(),
|
||||
enable_user_ads: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
233
crates/core/src/database/ads.rs
Normal file
233
crates/core/src/database/ads.rs
Normal 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:{}");
|
||||
}
|
|
@ -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?;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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");
|
||||
|
|
10
crates/core/src/database/drivers/sql/create_ads.sql
Normal file
10
crates/core/src/database/drivers/sql/create_ads.sql
Normal 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
|
||||
)
|
|
@ -1,3 +1,4 @@
|
|||
mod ads;
|
||||
pub mod app_data;
|
||||
mod apps;
|
||||
mod audit_log;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue