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 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
}

View file

@ -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"

View file

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

View file

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

View file

@ -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) {

View file

@ -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")

View file

@ -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")

View file

@ -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 %}")

View file

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

View file

@ -0,0 +1,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")
("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 "&lt;script src=\"{{ config.host }}\"/js/ads.js\"&gt;&lt;/script&gt;
&lt;script&gt;TetrattoAds.init&lpar;&rpar;; TetrattoAds.render_ads&lpar;\"{{ user.id }}\", \"{{ config.host }}\"&rpar;&lt;/script&gt;")))
(p (text "After you've done that, you can place your ads like so:"))
(pre (code (text "&lt;object class=\"tetratto_ad\" data-ad-size=\"$size$\"&gt;&lt;/object&gt;")))
(p
(text "In the above example, replace \"$size$\" with a size from ")
(a ("href" "https://tetratto.com/reference/tetratto/model/economy/enum.UserAdSize.html") (text "here"))
(text " keep in mind that the name of a size must be in title case. That means it's \"Leaderboard\", not \"leaderboard\"."))))
(text "{%- endif %}"))
(script
(text "async function create_product_from_form(e) {
@ -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 %}")

View file

@ -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")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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")

View file

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

View file

@ -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

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 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,
}

View file

@ -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()),
};

View file

@ -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());
}

View file

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

View file

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

View file

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

View file

@ -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()),
)
}

View file

@ -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,
}

View file

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