Compare commits

..

20 commits

Author SHA1 Message Date
83971b3d20 fix: ads balance check 2025-08-11 22:53:38 -04:00
9a5236bc1b fix: wallet page pagination 2025-08-11 20:45:51 -04:00
59bccd9474 add: automatically stop all user ads when user cannot afford transfer 2025-08-11 20:44:16 -04:00
2cb7d08ddc add: user ads 2025-08-11 20:21:05 -04:00
46b3e66cd4 fix: media gallery in apple internet explorer 2025-08-11 12:32:37 -04:00
ba319130d2 add: allow mods to remove associations 2025-08-10 22:26:13 -04:00
bcee3f7763 add: require that users keep supporter status for profilestyle products 2025-08-10 16:20:23 -04:00
59378a4447 add: don't allow free ProfileStyle products 2025-08-10 16:04:21 -04:00
7f0cb1f2a1 fix: user and post deletion 2025-08-10 12:32:20 -04:00
5a9160f612 fix: properly clean up product uploads 2025-08-10 00:19:57 -04:00
7a31dcbd9b add: product thumbnails ui 2025-08-10 00:17:21 -04:00
815e730fc0 fix: timeline loading 2025-08-09 17:47:06 -04:00
9ef9b9e579 add: allow 2 timeline retries instead of 1 2025-08-09 17:33:49 -04:00
e145449bc7 add: timeline build retries 2025-08-09 17:27:33 -04:00
fdaae8d977 add: transfer refunds 2025-08-09 14:00:46 -04:00
95cb889080 add: ProfileStyle products 2025-08-08 23:44:45 -04:00
077e9252e3 add: hide_username_badges 2025-08-08 21:05:56 -04:00
98426d0989 add: store coin transfer source 2025-08-08 16:01:23 -04:00
a08552338b fix: wallet panic 2025-08-08 14:52:53 -04:00
e5e6d5cddb add: single_use products 2025-08-08 14:17:40 -04:00
62 changed files with 2589 additions and 173 deletions

View file

@ -10,9 +10,9 @@ package.homepage = "https://tetratto.com"
incremental = true
[profile.release]
opt-level = "z"
opt-level = 3
lto = true
codegen-units = 1
codegen-units = 2
# panic = "abort"
panic = "unwind"
strip = true

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

@ -208,6 +208,7 @@ version = "1.0.0"
"settings:tab.billing" = "Billing"
"settings:tab.uploads" = "Uploads"
"settings:tab.invites" = "Invites"
"setttings:label.applied_configurations" = "Applied configurations"
"mod_panel:label.open_reported_content" = "Open reported content"
"mod_panel:label.manage_profile" = "Manage profile"
@ -337,9 +338,22 @@ version = "1.0.0"
"economy:label.create_new" = "Create new product"
"economy:label.price" = "Price"
"economy:label.on_sale" = "On sale"
"economy:label.single_use" = "Only allow users to purchase once"
"economy:label.stock" = "Stock"
"economy:label.unlimited" = "Unlimited"
"economy:label.fulfillment_style" = "Fulfillment style"
"economy:label.use_automail" = "Use automail"
"economy:label.automail_message" = "Automail message"
"economy:action.buy" = "Buy"
"economy:label.already_purchased" = "Already purchased"
"economy:label.snippet_data" = "Snippet data"
"economy:action.apply" = "Apply"
"economy:action.unapply" = "Unapply"
"economy:label.thumbnails" = "Thumbnails"
"economy:label.my_ads" = "My ads"
"economy:label.create_new_ad" = "Create new advertisement"
"economy:label.target" = "Target URL"
"economy:label.image" = "Image"
"economy:label.size_base" = "Size base"
"economy:label.running" = "Running"
"economy:label.embed_ads_on_my_site" = "Embed ads on my site"

View file

@ -201,8 +201,7 @@ p {
.name {
max-width: 250px;
overflow: hidden;
/* overflow-wrap: break-word; */
overflow-wrap: anywhere;
white-space: nowrap;
text-overflow: ellipsis;
}

View file

@ -1,5 +1,10 @@
@import url("root.css");
/* ads */
.tetratto_ad iframe {
border-radius: var(--radius);
}
/* media gallery */
.media_gallery {
display: grid;
@ -12,7 +17,9 @@
@media screen and (max-width: 900px) {
.media_gallery {
grid-auto-flow: row dense;
/* grid-auto-flow: row dense; */ /* safari is the most shit browser ever dude, this property causes safari to make images overlap for lord knows why */
display: flex;
flex-direction: column;
}
}
@ -556,6 +563,10 @@ input[type="checkbox"]:checked {
background-image: url("/icons/check.svg");
}
label {
cursor: pointer;
}
/* pillmenu */
.pillmenu {
display: flex;

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

@ -1,7 +1,6 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Community settings - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex_col gap_2")
@ -303,7 +302,7 @@
("minlength" "2")
("maxlength" "32")))
(button
(text "{{ text \"communities:action.create\" }}"))))
(str (text "communities:action.create")))))
(text "{% for channel in channels %}")
(div
("class" "card_nest")
@ -447,7 +446,7 @@
(text "{{ text \"communities:label.upload\" }}")))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "upload_emoji(event)")
("onsubmit" "create_emoji_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
@ -473,11 +472,10 @@
("accept" "image/png,image/jpeg,image/avif,image/webp")
("class" "w_full")))
(button
(text "{{ text \"communities:action.create\" }}"))
(str (text "communities:action.create")))
(span
("class" "fade")
(text "Emojis can be a maximum of 256 KiB, or 512x512px (width x
height)."))))
(text "Emojis can be a maximum of 256 KiB."))))
(text "{% for emoji in emojis %}")
(div
("class" "card secondary flex flex_wrap gap_2 items_center justify_between")
@ -506,7 +504,7 @@
(text "{{ text \"stacks:label.remove\" }}")))))
(text "{% endfor %}"))
(script
(text "globalThis.upload_emoji = (e) => {
(text "globalThis.create_emoji_from_form = (e) => {
e.preventDefault();
e.target.querySelector(\"button\").style.display = \"none\";
@ -525,6 +523,10 @@
]);
e.target.querySelector(\"button\").removeAttribute(\"style\");
if (res.ok) {
e.target.reset();
}
});
alert(\"Emoji upload in progress. Please wait!\");
@ -644,7 +646,7 @@
("min" "0")
("max" "256")))
(button
(text "{{ text \"communities:action.create\" }}"))))
(str (text "communities:action.create")))))
(text "{% for id, topic in community.topics %}")
(div
("class" "card_nest")

View file

@ -87,13 +87,13 @@
(span
(text "{{ dislikes }}"))
(text "{%- endif %}"))
(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}")
(text "{%- endif %} {%- endmacro %} {% macro full_username(user, wrap=true, max_width=\"180px\") -%} {% if user and user.username -%}")
(div
("class" "flex items_center")
("class" "flex {% if wrap -%} flex_wrap {%- endif %} items_center")
(a
("href" "/@{{ user.username }}")
("class" "flush flex gap_1")
("style" "font-weight: 600")
("class" "name flush flex gap_1")
("style" "font-weight: 600; max-width: {{ max_width }}")
("target" "_top")
(text "{% if user.settings.private_profile -%}")
(span
@ -107,7 +107,7 @@
(text "{% else %}")
(text "{{ self::username(user=user) }}")
(text "{%- endif %}"))
(text "{{ self::online_indicator(user=user) }} {% if user.is_verified -%}")
(text "{{ self::online_indicator(user=user) }} {% if not user.settings.hide_username_badges -%} {% if user.is_verified -%}")
(span
("title" "Verified")
("style" "color: var(--color-primary)")
@ -119,13 +119,19 @@
("style" "color: var(--color-primary);")
("class" "flex items_center")
(text "{{ icon \"star\" }}"))
(text "{%- endif %} {% if user.checkouts|length > 0 -%}")
(span
("title" "Donator")
("style" "color: var(--color-primary);")
("class" "flex items_center")
(text "{{ icon \"hand-heart\" }}"))
(text "{%- endif %} {% if user.permissions|has_staff_badge -%}")
(span
("title" "Staff")
("style" "color: var(--color-primary);")
("class" "flex items_center")
(text "{{ icon \"shield-user\" }}"))
(text "{%- endif %}"))
(text "{%- endif %} {%- endif %}"))
(text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}")
(div
("style" "display: contents")
@ -378,7 +384,7 @@
(text "{{ self::avatar(username=owner.username, size=\"24px\", selector_type=\"username\") }}"))
(text "{%- endif %}")
(span
("class" "name")
; ("class" "name")
(text "{{ self::full_username(user=owner) }}"))
(text "{{ self::post_info(post=post, community=community) }}")
(text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}"))
@ -459,14 +465,15 @@
(text "{% if community and show_community and community.id != config.town_square or question %}"))
(text "{%- endif %} {%- endmacro %}")
(text "{% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
(text "{% macro post_media(upload_ids, custom_click=false) -%} {% if upload_ids|length > 0 -%}")
(div
("class" "media_gallery gap_2")
(text "{% for upload in upload_ids %}")
(img
("src" "/api/v1/uploads/{{ upload }}")
("data-upload-id" "{{ upload }}")
("alt" "Image upload")
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])"))
("onclick" "{% if custom_click -%} {{ custom_click }} {%- else -%} trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}']) {%- endif %}"))
(text "{% endfor %}"))
(text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}")
(div
@ -617,9 +624,8 @@
(div
("class" "flex items_center")
(b
(text "{{ self::username(user=user) }}"))
(text "{{ self::full_username(user=user) }}"))
(text "{{ self::online_indicator(user=user) }}"))))
(text "{%- endmacro %} {% macro pagination(page=0, items=0, key=\"\", value=\"\") -%}")
(div
("class" "flex justify_between gap_2 w_full")
@ -888,7 +894,7 @@
(div
("class" "flex gap_2")
(button
(text "{{ text \"communities:action.create\" }}"))
(str (text "communities:action.create")))
(text "{% if drawing_enabled -%}")
(button
@ -1344,15 +1350,15 @@
(text "Listening to "))
(text "{{ other_user.connections.Spotify[1].data.artist }}")))
(text "{%- endif %} {%- endmacro %} {% macro user_plate(user, show_menu=false, show_kick=false, secondary=false) -%}")
(text "{%- endif %} {%- endmacro %} {% macro user_plate(user, show_menu=false, show_kick=false, secondary=false, full=false) -%}")
(div
("class" "flex gap_2 items_center card tiny user_plate {% if secondary -%}secondary{%- endif %}")
("class" "flex gap_2 items_center card tiny user_plate {% if secondary -%}secondary{%- endif %} {% if full -%} w_full {%- endif %}")
(a
("href" "/@{{ user.username }}")
(text "{{ self::avatar(username=user.username, size=\"42px\", selector_type=\"username\") }}"))
(div
("class" "flex justify_center flex_col")
("style" "{% if show_menu or show_kick -%}width: 60%{% else %}max-width: 150px{%- endif %}")
("style" "{% if show_menu or show_kick -%}width: 60%{% else %}max-width: calc(100% - 42px - var(--pad-4)){%- endif %}")
(text "{{ self::full_username(user=user) }}")
(div
("class" "user_status")
@ -2485,6 +2491,13 @@
(text "Create infinite Littleweb sites"))
(li
(text "Create infinite Littleweb domains"))
(li
(text "Create and sell CSS snippet products"))
(text "{% if config.enable_user_ads -%}")
(li
(text "No ads"))
(text "{%- endif %}")
(text "{% if config.security.enable_invite_codes -%}")
(li
@ -2701,3 +2714,33 @@
(icon (text "badge-cent"))
(text "{{ product.price }}")))
(text "{%- endmacro %}")
(text "{% macro ad_listing_card(ad) -%}")
(a
("class" "card button lowered w_full flex flex_col gap_2")
("href" "/product/ad/{{ ad.id }}/edit")
(b
("class" "flex gap_2 items_center")
("style" "height: 24px; text-decoration: {% if not ad.is_running -%} line-through {%- else -%} none {%- endif %}")
(icon (text "link"))
(text "{{ ad.target }}"))
(b
("style" "height: 18px")
(text "{% if ad.is_running -%}")
(span
("class" "green flex gap_2 items_center")
(icon (text "circle-check"))
(text "Running"))
(text "{% else %}")
(span
("class" "red flex gap_2 items_center")
(icon (text "circle-x"))
(text "Not running"))
(text "{%- endif %}")))
(text "{%- endmacro %}")
(text "{% macro advertisement(size=\"Leaderboard\") -%}")
(text "{% if not is_supporter and config.enable_user_ads -%}")
(object ("class" "tetratto_ad") ("data-ad-size" "{{ size }}"))
(text "{%- endif %}")
(text "{%- endmacro %}")

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,63 @@
(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;
top: 0.5rem;
left: 0.5rem;
padding: 0.15rem 0.5rem;
background: hsla(0, 0%, 0%, 50%);
color: white;
font-weight: 600;
font-size: 10px;
user-select: none;
pointer-events: none;
border-radius: 0.4rem;
box-shadow: 0 0 2px hsla(0, 0%, 0%, 25%);
opacity: 25%;
}"))))

View file

@ -4,6 +4,23 @@
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
(main
("class" "flex flex_col gap_2")
(div
("class" "card_nest")
(div
("class" "card small flex items_center gap_2")
(icon (text "images"))
(b
(str (text "economy:label.thumbnails"))))
(div
("class" "card flex flex_col gap_2")
(text "{{ components::post_media(upload_ids=product.uploads.thumbnails, custom_click=\"remove_thumbnail(event.target)\") }}")
(text "{% if product.uploads.thumbnails|length < 4 -%}")
(button
("onclick" "add_thumbnail()")
(icon (text "plus"))
(str (text "communities:label.upload")))
(text "{%- endif %}")))
(div
("class" "card_nest")
(div
@ -77,10 +94,22 @@
("oninput" "event.preventDefault(); update_on_sale_from_form(event.target.checked)"))
(span
(str (text "economy:label.on_sale"))))
(label
("for" "single_use")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "single_use")
("name" "single_use")
("class" "w_content")
("checked" "{{ product.single_use }}")
("oninput" "event.preventDefault(); update_single_use_from_form(event.target.checked)"))
(span
(str (text "economy:label.single_use"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
("for" "price")
(str (text "economy:label.price")))
(input
("type" "number")
@ -118,7 +147,7 @@
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
("for" "stock")
(str (text "economy:label.stock")))
(input
("type" "number")
@ -138,44 +167,158 @@
(icon (text "package-check"))
(b
(str (text "economy:label.fulfillment_style"))))
(form
(div
("class" "card flex flex_col gap_2")
("onsubmit" "update_method_from_form(event)")
(p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below."))
(p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized."))
(select
("id" "fulfillment_style_select")
("onchange" "mirror_fulfillment_style_select(true)")
(option ("value" "mail") (text "Mail") ("selected" "{{ not product.method == \"ProfileStyle\" }}"))
(option ("value" "snippet") (text "CSS Snippet") ("selected" "{{ product.method == \"ProfileStyle\" }}")))
(form
("class" "flex flex_col gap_2 hidden")
("id" "mail_fulfillment")
("onsubmit" "update_method_from_form(event)")
(p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below."))
(p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized."))
(text "{% set is_automail = product.method != \"ManualMail\" and product.method != \"ProfileStyle\" %}")
(label
("for" "use_automail")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "use_automail")
("name" "use_automail")
("class" "w_content")
("oninput" "mirror_use_automail()")
("checked" "{% if product.method != \"ManualMail\" -%} true {%- else -%} false {%- endif %}"))
(span
(str (text "economy:label.use_automail"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "automail_message")
(str (text "economy:label.automail_message")))
(textarea
("name" "automail_message")
("id" "automail_message")
("placeholder" "automail_message")
(text "{% if product.method != \"ManualMail\" -%} {{ product.method.AutoMail }} {%- endif %}")))
(button (str (text "general:action.save")))))
("for" "use_automail")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "use_automail")
("name" "use_automail")
("class" "w_content")
("oninput" "mirror_use_automail()")
("checked" "{% if is_automail -%} true {%- else -%} false {%- endif %}"))
(span
(str (text "economy:label.use_automail"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "automail_message")
(str (text "economy:label.automail_message")))
(textarea
("name" "automail_message")
("id" "automail_message")
("placeholder" "automail_message")
(text "{% if is_automail -%} {{ product.method.AutoMail }} {%- endif %}")))
(button (str (text "general:action.save"))))
(form
("class" "flex flex_col gap_2 hidden")
("id" "snippet_fulfillment")
("onsubmit" "update_data_from_form(event)")
(text "{{ components::supporter_ad(body=\"Become a supporter to create snippets!\") }}")
(div
("class" "flex flex_col gap_1")
(label
("for" "data")
(str (text "economy:label.snippet_data")))
(textarea
("name" "data")
("id" "data")
("placeholder" "data")
(text "{{ product.data }}")))
(button (str (text "general:action.save"))))))
(a
("class" "button secondary")
("href" "/product/{{ product.id }}")
(icon (text "arrow-left"))
(str (text "general:action.back"))))
(div
("class" "flex gap_2")
(a
("class" "button secondary")
("href" "/product/{{ product.id }}")
(icon (text "arrow-left"))
(str (text "general:action.back")))
(button
("class" "lowered red")
("onclick" "delete_product()")
(icon (text "trash"))
(str (text "general:action.delete")))))
(script
(text "async function update_title_from_form(e) {
(text "async function add_thumbnail() {
await trigger(\"atto::debounce\", [\"products::update\"]);
const picker = document.createElement(\"input\");
picker.type = \"file\";
picker.accept = \"image/*\";
document.body.appendChild(picker);
picker.click();
picker.addEventListener(\"change\", () => {
// create body
const body = new FormData();
for (const file of picker.files) {
body.append(file.name, file);
}
body.append(
\"body\",
JSON.stringify({
target: \"Thumbnails\"
}),
);
// ...
picker.remove();
fetch(\"/api/v1/products/{{ product.id }}/uploads\", {
method: \"POST\",
body,
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.reload();
}, 100);
}
});
});
}
async function remove_thumbnail(target) {
await trigger(\"atto::debounce\", [\"products::update\"]);
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/products/{{ product.id }}/uploads/thumbnails\", {
method: \"DELETE\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
idx: Array.from(target.parentElement.children).findIndex((x) => x.getAttribute(\"data-upload-id\") === target.getAttribute(\"data-upload-id\")),
}),
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.reload();
}, 100);
}
});
}
async function update_title_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
@ -240,6 +383,27 @@
});
}
async function update_single_use_from_form(single_use) {
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/single_use\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
single_use,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_price_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
@ -306,6 +470,49 @@
});
}
async function update_data_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/data\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
data: e.target.data.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function delete_product() {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/products/{{ product.id }}\", {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.mirror_use_automail = () => {
const use_automail = document.getElementById(\"use_automail\").checked;
@ -316,7 +523,46 @@
}
}
globalThis.mirror_fulfillment_style_select = (send = false) => {
const selected = document.getElementById(\"fulfillment_style_select\").selectedOptions[0].value;
if (selected === \"mail\") {
document.getElementById(\"mail_fulfillment\").classList.remove(\"hidden\");
document.getElementById(\"snippet_fulfillment\").classList.add(\"hidden\");
if (send) {
update_method_from_form({
preventDefault: () => {},
target: document.getElementById(\"mail_fulfillment\"),
});
}
} else {
document.getElementById(\"mail_fulfillment\").classList.add(\"hidden\");
document.getElementById(\"snippet_fulfillment\").classList.remove(\"hidden\");
if (send) {
fetch(\"/api/v1/products/{{ product.id }}/method\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
method: \"ProfileStyle\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
}
}
setTimeout(() => {
mirror_use_automail();
mirror_fulfillment_style_select();
}, 150);"))
(text "{% endblock %}")

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

@ -4,6 +4,7 @@
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
(main
("class" "flex flex_col gap_2")
(text "{{ components::post_media(upload_ids=product.uploads.thumbnails) }}")
(div
("class" "card flex flex_col gap_2")
(h3
@ -19,21 +20,46 @@
("class" "card lowered w_full no_p_margin")
(text "{{ product.description|markdown|safe }}"))
(text "{% if already_purchased -%}")
(span
("class" "green flex items_center gap_2")
(icon (text "circle-check"))
(str (text "economy:label.already_purchased")))
(text "{%- endif %}")
(div
("class" "flex gap_2 items_center")
(text "{% if user.id != product.owner -%}")
(text "{% if not already_purchased -%}")
; price
(a
("class" "button camo lowered")
("href" "/wallet")
("target" "_blank")
(icon (text "badge-cent"))
(text "{{ product.price }}"))
(text "{% if user.id != product.owner -%}")
; buy button
(button
("onclick" "purchase()")
("disabled" "{{ product.stock == 0 }}")
(icon (text "piggy-bank"))
(str (text "economy:action.buy")))
(text "{% else %}")
; profile style snippets
(text "{% if product.method == \"ProfileStyle\" -%} {% if not product.id in applied_configurations_mapped -%}")
(button
("onclick" "apply()")
(icon (text "check"))
(str (text "economy:action.apply")))
(text "{% else %}")
(button
("onclick" "remove()")
(icon (text "x"))
(str (text "economy:action.unapply")))
(text "{%- endif %} {%- endif %}")
; ...
(text "{%- endif %}")
(text "{% else %}")
(a
("class" "button")
("href" "/product/{{ product.id }}/edit")
@ -62,6 +88,59 @@
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
async function apply() {
await trigger(\"atto::debounce\", [\"user::update\"]);
fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
\"type\": \"StyleSnippet\",
\"id\": \"{{ product.id }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
async function remove() {
await trigger(\"atto::debounce\", [\"user::update\"]);
fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", {
method: \"DELETE\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
\"id\": \"{{ product.id }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}"))
(text "{% endblock %}")

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

@ -11,7 +11,13 @@
(span
("class" "flex items_center gap_2")
(icon (text "piggy-bank"))
(span (str (text "general:link.wallet")))))
(span (str (text "general:link.wallet"))))
(button
("class" "lowered small square tiny big_icon")
("onclick" "document.getElementById('buy_dialog').showModal()")
("title" "Buy coins")
(icon (text "plus"))))
(div
("class" "card lowered flex flex_col gap_4")
(button
@ -32,7 +38,7 @@
(icon (text "clock"))
(span (str (text "economy:label.recent_transfers")))))
(div
("class" "card lowered flex flex_col gap_4")
("class" "card flex flex_col gap_4")
(div
("class" "w_full")
("style" "overflow: auto")
@ -43,26 +49,39 @@
(th (text "Sender"))
(th (text "Receiver"))
(th (text "Amount"))
(th (text "Product")))
(th (text "Product"))
(th (text "Source"))
(th (text "Actions")))
(tbody
(text "{% for transfer in list -%}")
(tr
(td (span ("class" "date short") (text "{{ transfer[1] }}")))
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[3]) }}"))
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[4]) }}"))
(td (span ("class" "date short") (text "{{ transfer[3].created }}")))
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[0], wrap=false) }}"))
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[1], wrap=false) }}"))
(td
("class" "flex items_center gap_1")
(text "{{ transfer[2] }}")
(text "{% if transfer[6] -%}")
(text "{{ transfer[3].amount }}")
(text "{% if transfer[3].is_pending -%}")
(span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock")))
(text "{%- endif %}"))
(td
(text "{% if transfer[5] -%}")
(text "{% if transfer[2] -%}")
(a
("href" "/product/{{ transfer[5].id }}")
("href" "/product/{{ transfer[2].id }}")
(icon (text "external-link")))
(text "{%- endif %}"))
(td (text "{{ transfer[3].source }}"))
(td
(text "{% if user.id == transfer[1].id and transfer[3].source == '\"Sale\"' -%}")
; we're the receiver
(button
("class" "small tiny square raised camo big_icon")
("onclick" "issue_refund('{{ transfer[3].id }}')")
("title" "Issue refund")
(icon (text "undo")))
(text "{%- endif %}")))
(text "{%- endfor %}")))))))
(text "{%- endfor %}"))))
(text "{{ components::pagination(page=page, items=list|length) }}"))))
(dialog
("id" "buy_dialog")
@ -112,5 +131,23 @@
window.location.href = res.payload;
}
});
}
globalThis.issue_refund = async (transfer) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/transfers/${transfer}/refund`, {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]);
});
}"))
(text "{% endblock %}")

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

@ -79,6 +79,7 @@
("href" "/mail")
(icon (text "mail"))
(str (text "general:link.mail")))
(text "{% if config.stripe -%}")
(a
("href" "/wallet")
(icon (text "piggy-bank"))
@ -87,6 +88,7 @@
("href" "/products")
(icon (text "store"))
(str (text "economy:label.my_products")))
(text "{%- endif %}")
(a
("href" "/journals/0/0")
(icon (text "notebook"))

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

@ -84,7 +84,7 @@
const ui = await ns(\"ui\");
const element = document.getElementById(\"mod_options\");
globalThis.profile_request = async (do_confirm, path, body) => {
globalThis.profile_request = async (do_confirm, path, body = null, headers = { \"Content-Type\": \"application/json\" }, method = \"POST\") => {
if (do_confirm) {
if (
!(await trigger(\"atto::confirm\", [
@ -96,11 +96,9 @@
}
fetch(`/api/v1/auth/user/{{ profile.id }}/${path}`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify(body),
method,
headers: headers != null ? headers : undefined,
body: body != null ? JSON.stringify(body) : undefined,
})
.then((res) => res.json())
.then((res) => {
@ -265,9 +263,15 @@
(span
(text "{{ text \"mod_panel:label.associations\" }}"))))
(div
("class" "card lowered flex flex_wrap gap_2")
("class" "card flex flex_wrap gap_4 flex_collapse")
(text "{% for user in associations -%}")
(text "{{ components::user_plate(user=user, show_menu=false) }}")
(div
("class" "flex flex_row gap_2 items_center card small secondary")
(text "{{ components::user_plate(user=user, show_menu=false, secondary=true, full=true) }}")
(button
("class" "small square red lowered")
("onclick" "profile_request(true, 'associations/{{ user.id }}', null, null, 'DELETE')")
(icon (text "x"))))
(text "{%- endfor %}")))
(text "{% if invite -%}")
(div

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

@ -304,7 +304,7 @@
globalThis.request_transfer = async () => {
await trigger(\"atto::debounce\", [\"economy::transfer\"]);
const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"\");
const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"0\");
if (amount === 0) {
return;
@ -468,6 +468,12 @@
("class" "rhs w_full flex flex_col gap_4")
(text "{% block content %}{% endblock %}")))))
(text "{% if not use_user_theme -%}")
(text "{% for cnf in applied_configurations -%}")
(text "{{ cnf|safe }}")
(text "{%- endfor %}")
(text "{%- endif %}")
(text "{% if not is_self and profile.settings.warning -%}")
(script
(text "setTimeout(() => {

View file

@ -1162,6 +1162,26 @@
("class" "fade")
(text "This represents the site theme shown to users viewing
your profile.")))))
(text "{% if profile.applied_configurations|length > 0 -%}")
(div
("class" "card_nest")
("ui_ident" "applied_configurations")
(div
("class" "card small flex items_center gap_2")
(icon (text "cog"))
(str (text "setttings:label.applied_configurations")))
(div
("class" "card")
(p (text "Products that you have purchased and applied to your profile are displayed below. Snippets are always synced to the product, meaning the owner could update it at any time."))
(ul
(text "{% for cnf in profile.applied_configurations -%}")
(li
(text "{{ cnf[0] }} ")
(a
("href" "/product/{{ cnf[1] }}")
(text "{{ cnf[1] }}")))
(text "{%- endfor %}"))))
(text "{%- endif %}")
(button
("onclick" "save_settings()")
("id" "save_button")
@ -1742,6 +1762,7 @@
\"import_export\",
\"theme_preference\",
\"profile_theme\",
\"applied_configurations\",
]);
ui.generate_settings_ui(
@ -1906,6 +1927,14 @@
\"{{ profile.settings.hide_social_follows }}\",
\"checkbox\",
],
[
[
\"hide_username_badges\",
\"Hide badges from your username (outside of your profile)\",
],
\"{{ profile.settings.hide_username_badges }}\",
\"checkbox\",
],
[[], \"Questions\", \"title\"],
[
[

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,146 @@
use crate::{
cookie::CookieJar,
get_user_from_token,
image::{save_webp_buffer, JsonMultipart},
State,
};
use axum::{
extract::Path,
response::{IntoResponse, Redirect},
Extension, Json,
};
use tetratto_core::model::{
economy::UserAd,
oauth,
uploads::{MediaType, MediaUpload},
ApiReturn, Error,
};
use super::{CreateAd, UpdateAdIsRunning};
const MAXIMUM_AD_FILE_SIZE: usize = 2_097_152;
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<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()),
};
let ad = match data.get_ad_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if !ad.is_running && user.coins < 50 {
return Json(
Error::MiscError(
"You must have a minimum of 50 coins in your balance to run ads".to_string(),
)
.into(),
);
}
match data
.update_ad_is_running(id, &user, if req.is_running { 1 } else { 0 })
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Ad updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn click_request(
jar: CookieJar,
Extension(data): Extension<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

@ -7,7 +7,7 @@ use axum::{
};
use tetratto_core::model::{
auth::{Notification, User},
economy::{CoinTransfer, CoinTransferMethod},
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource},
moderation::AuditLogEntry,
permissions::{FinePermission, SecondaryPermission},
ApiReturn, Error,
@ -635,6 +635,7 @@ pub async fn handle_stupid_fucking_checkout_success_session(
user.id,
100,
CoinTransferMethod::Transfer,
CoinTransferSource::Purchase,
),
true,
)
@ -651,6 +652,7 @@ pub async fn handle_stupid_fucking_checkout_success_session(
user.id,
400,
CoinTransferMethod::Transfer,
CoinTransferSource::Purchase,
),
true,
)

View file

@ -3,10 +3,11 @@ use crate::{
get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::{
AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanExpire,
UpdateUserBanReason, UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified,
UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
AddAppliedConfiguration, AppendAssociations, AwardAchievement, DeleteUser, DisableTotp,
RefreshGrantToken, RemoveAppliedConfiguration, UpdateSecondaryUserRole,
UpdateUserAwaitingPurchase, UpdateUserBanExpire, UpdateUserBanReason, UpdateUserInviteCode,
UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole,
UpdateUserUsername,
},
State,
};
@ -24,6 +25,7 @@ use tetratto_core::{
cache::Cache,
model::{
auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS},
economy::CoinTransferMethod,
moderation::AuditLogEntry,
oauth,
permissions::FinePermission,
@ -180,8 +182,108 @@ pub async fn update_user_settings_request(
}
}
/// Add the given applied configuration.
pub async fn add_applied_configuration_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<AddAppliedConfiguration>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
let product_id: usize = match req.id.parse() {
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
let product = match data.get_product_by_id(product_id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if data
.get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id))
.await
.is_err()
{
return Json(Error::NotAllowed.into());
}
// update
user.applied_configurations.push((req.r#type, product.id));
// ...
match data
.update_user_applied_configurations(id, user.applied_configurations)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Applied configurations updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Remove the given applied configuration.
pub async fn remove_applied_configuration_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<RemoveAppliedConfiguration>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
let product_id: usize = match req.id.parse() {
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
// update
user.applied_configurations.remove(
match user
.applied_configurations
.iter()
.position(|x| x.1 == product_id)
{
Some(x) => x,
None => return Json(Error::GeneralNotFound("configuration".to_string()).into()),
},
);
// ...
match data
.update_user_applied_configurations(id, user.applied_configurations)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Applied configurations updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Append associations to the current user.
pub async fn append_associations_request(
pub async fn append_association_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(req): Json<AppendAssociations>,
@ -229,6 +331,50 @@ pub async fn append_associations_request(
}
}
/// Remove an association from the given user.
pub async fn remove_association_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((uid, association)): Path<(usize, usize)>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
// get user
let mut other_user = match data.get_user_by_id(uid).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
// find association and remove
other_user.associated.remove(
match other_user.associated.iter().position(|x| x == &association) {
Some(x) => x,
None => return Json(Error::GeneralNotFound("association".to_string()).into()),
},
);
// ...
match data
.update_user_associated(other_user.id, other_user.associated)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Associations updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Update the password of the given user.
///
/// Does not support third-party grants.

View file

@ -1,3 +1,4 @@
pub mod ads;
pub mod app_data;
pub mod apps;
pub mod auth;
@ -25,13 +26,13 @@ use axum::{
use serde::Deserialize;
use tetratto_core::model::{
apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota},
auth::AchievementName,
auth::{AchievementName, AppliedConfigType},
communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
PollOption, PostContext,
},
communities_permissions::CommunityPermission,
economy::ProductFulfillmentMethod,
economy::{ProductFulfillmentMethod, UserAdSize},
journals::JournalPrivacyPermission,
littleweb::{DomainData, DomainTld, ServiceFsEntry},
oauth::AppScope,
@ -333,6 +334,14 @@ pub fn routes() -> Router {
"/auth/user/{id}/settings",
post(auth::profile::update_user_settings_request),
)
.route(
"/auth/user/{id}/applied_configuration",
post(auth::profile::add_applied_configuration_request),
)
.route(
"/auth/user/{id}/applied_configuration",
delete(auth::profile::remove_applied_configuration_request),
)
.route(
"/auth/user/{id}/role",
post(auth::profile::update_user_role_request),
@ -389,6 +398,10 @@ pub fn routes() -> Router {
"/auth/user/{id}/totp/codes",
post(auth::profile::refresh_totp_codes_request),
)
.route(
"/auth/user/{id}/associations/{association}",
delete(auth::profile::remove_association_request),
)
.route(
"/auth/user/{username}/totp/check",
get(auth::profile::has_totp_enabled_request),
@ -396,7 +409,7 @@ pub fn routes() -> Router {
.route("/auth/user/me/seen", post(auth::profile::seen_request))
.route(
"/auth/user/me/append_associations",
put(auth::profile::append_associations_request),
put(auth::profile::append_association_request),
)
.route("/auth/user/find/{id}", get(auth::profile::redirect_from_id))
.route(
@ -719,6 +732,10 @@ pub fn routes() -> Router {
// transfers
.route("/transfers", post(transfers::create_request))
.route("/transfers/ask", post(transfers::ask_request))
.route(
"/transfers/{id}/refund",
post(transfers::create_refund_request),
)
// products
.route("/products", post(products::create_request))
.route("/products/{id}", delete(products::delete_request))
@ -728,16 +745,34 @@ pub fn routes() -> Router {
"/products/{id}/description",
post(products::update_description_request),
)
.route("/products/{id}/data", post(products::update_data_request))
.route(
"/products/{id}/on_sale",
post(products::update_on_sale_request),
)
.route(
"/products/{id}/single_use",
post(products::update_single_use_request),
)
.route("/products/{id}/price", post(products::update_price_request))
.route(
"/products/{id}/method",
post(products::update_method_request),
)
.route("/products/{id}/stock", post(products::update_stock_request))
.route(
"/products/{id}/uploads",
post(products::update_uploads_request),
)
.route(
"/products/{id}/uploads/thumbnails",
delete(products::remove_thumbnail_request),
)
// ads
.route("/ads", post(ads::create_request))
.route("/ads/{id}", delete(ads::delete_request))
.route("/ads/{id}/running", post(ads::update_is_running_request))
.route("/ads/host/{host}/{id}/click", get(ads::click_request))
}
pub fn lw_routes() -> Router {
@ -1270,11 +1305,21 @@ pub struct UpdateProductDescription {
pub description: String,
}
#[derive(Deserialize)]
pub struct UpdateProductData {
pub data: String,
}
#[derive(Deserialize)]
pub struct UpdateProductOnSale {
pub on_sale: bool,
}
#[derive(Deserialize)]
pub struct UpdateProductSingleUse {
pub single_use: bool,
}
#[derive(Deserialize)]
pub struct UpdateProductPrice {
pub price: i32,
@ -1289,3 +1334,41 @@ pub struct UpdateProductMethod {
pub struct UpdateProductStock {
pub stock: i32,
}
#[derive(Deserialize)]
pub struct AddAppliedConfiguration {
pub r#type: AppliedConfigType,
pub id: String,
}
#[derive(Deserialize)]
pub struct RemoveAppliedConfiguration {
pub id: String,
}
#[derive(Deserialize, PartialEq, Eq)]
pub enum ProductUploadTarget {
Thumbnails,
Reward,
}
#[derive(Deserialize)]
pub struct UpdateProductUploads {
pub target: ProductUploadTarget,
}
#[derive(Deserialize)]
pub struct RemoveProductThumbnail {
pub idx: usize,
}
#[derive(Deserialize)]
pub struct CreateAd {
pub target: String,
pub size: UserAdSize,
}
#[derive(Deserialize)]
pub struct UpdateAdIsRunning {
pub is_running: bool,
}

View file

@ -1,9 +1,21 @@
use crate::{get_user_from_token, State, cookie::CookieJar};
use crate::{
cookie::CookieJar,
get_user_from_token,
image::{save_webp_buffer, JsonMultipart},
State,
};
use axum::{extract::Path, response::IntoResponse, Extension, Json};
use tetratto_core::model::{economy::Product, oauth, ApiReturn, Error};
use tetratto_core::model::{
economy::{Product, ProductFulfillmentMethod},
oauth,
permissions::FinePermission,
uploads::{MediaType, MediaUpload},
ApiReturn, Error,
};
use super::{
CreateProduct, UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale,
UpdateProductPrice, UpdateProductStock, UpdateProductTitle,
CreateProduct, ProductUploadTarget, RemoveProductThumbnail, UpdateProductData,
UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, UpdateProductPrice,
UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, UpdateProductUploads,
};
pub async fn create_request(
@ -36,7 +48,7 @@ pub async fn delete_request(
Path(id): Path<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()),
};
@ -112,6 +124,33 @@ pub async fn update_description_request(
}
}
pub async fn update_data_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(mut req): Json<UpdateProductData>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
req.data = req.data.trim().to_string();
if req.data.len() > 16384 {
return Json(Error::DataTooLong("data".to_string()).into());
}
match data.update_product_data(id, &user, &req.data).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_on_sale_request(
jar: CookieJar,
Extension(data): Extension<State>,
@ -137,6 +176,31 @@ pub async fn update_on_sale_request(
}
}
pub async fn update_single_use_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateProductSingleUse>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.update_product_single_use(id, &user, if req.single_use { 1 } else { 0 })
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_price_request(
jar: CookieJar,
Extension(data): Extension<State>,
@ -149,7 +213,14 @@ pub async fn update_price_request(
None => return Json(Error::NotAllowed.into()),
};
if req.price < 25 {
let product = match data.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
let can_be_free = product.method != ProductFulfillmentMethod::ProfileStyle;
if req.price < 25 && (!can_be_free || req.price != 0) {
return Json(
Error::MiscError(
"Price is too low, please use a price of 25 coins or more".to_string(),
@ -180,6 +251,24 @@ pub async fn update_method_request(
None => return Json(Error::NotAllowed.into()),
};
if req.method == ProductFulfillmentMethod::ProfileStyle
&& !user.permissions.check(FinePermission::SUPPORTER)
{
return Json(Error::RequiresSupporter.into());
}
let product = match data.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if req.method == ProductFulfillmentMethod::ProfileStyle && product.price == 0 {
// no free profile styles
if let Err(e) = data.update_product_price(id, &user, 25).await {
return Json(e.into());
}
}
match data.update_product_method(id, &user, req.method).await {
Ok(_) => Json(ApiReturn {
ok: true,
@ -232,3 +321,163 @@ pub async fn buy_request(
Err(e) => Json(e.into()),
}
}
const MAXIMUM_THUMBNAIL_FILE_SIZE: usize = 2_097_152;
const MAXIMUM_REWARD_FILE_SIZE: usize = 4_194_304;
/// Update the product's uploads. Only reads one multipart file entry.
pub async fn update_uploads_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
JsonMultipart(bytes_parts, req): JsonMultipart<UpdateProductUploads>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let mut product = match data.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
// apply to target
match req.target {
ProductUploadTarget::Thumbnails => {
if product.uploads.thumbnails.len() == 4 {
return Json(
Error::MiscError("Too many thumbnails exist. Please remove one".to_string())
.into(),
);
}
// create upload
let file = match bytes_parts.get(0) {
Some(x) => x,
None => return Json(Error::Unknown.into()),
};
if file.len() > MAXIMUM_THUMBNAIL_FILE_SIZE {
return Json(Error::FileTooLarge.into());
}
let upload = match data
.create_upload(MediaUpload::new(MediaType::Webp, user.id))
.await
{
Ok(x) => x,
Err(e) => return Json(e.into()),
};
product.uploads.thumbnails.push(upload.id);
// write image
if let Err(e) =
save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
{
return Json(Error::MiscError(e.to_string()).into());
}
}
ProductUploadTarget::Reward => {
// remove old
if product.uploads.reward != 0 {
if let Err(e) = data.delete_upload(product.uploads.reward).await {
return Json(e.into());
}
}
// create upload
let file = match bytes_parts.get(0) {
Some(x) => x,
None => return Json(Error::Unknown.into()),
};
if file.len() > MAXIMUM_REWARD_FILE_SIZE {
return Json(Error::FileTooLarge.into());
}
let upload = match data
.create_upload(MediaUpload::new(MediaType::Webp, user.id))
.await
{
Ok(x) => x,
Err(e) => return Json(e.into()),
};
product.uploads.reward = upload.id;
// write image
if let Err(e) =
save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
{
return Json(Error::MiscError(e.to_string()).into());
}
}
}
// ...
match data
.update_product_uploads(id, &user, product.uploads)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn remove_thumbnail_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<RemoveProductThumbnail>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let mut product = match data.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
// remove upload
let thumbnail = match product.uploads.thumbnails.get(req.idx) {
Some(x) => x,
None => return Json(Error::GeneralNotFound("thumbnail".to_string()).into()),
};
if let Err(e) = data.delete_upload(*thumbnail).await {
return Json(e.into());
}
product.uploads.thumbnails.remove(req.idx);
// ...
match data
.update_product_uploads(id, &user, product.uploads)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -1,7 +1,7 @@
use crate::{get_user_from_token, State, cookie::CookieJar};
use axum::{response::IntoResponse, Extension, Json};
use axum::{response::IntoResponse, Extension, Json, extract::Path};
use tetratto_core::model::{
economy::{CoinTransfer, CoinTransferMethod},
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource},
oauth,
requests::{ActionData, ActionRequest, ActionType},
ApiReturn, Error,
@ -29,6 +29,7 @@ pub async fn create_request(
},
req.amount,
CoinTransferMethod::Transfer, // this endpoint is ONLY for regular transfers; products export a buy endpoint for the other method
CoinTransferSource::General,
),
true,
)
@ -74,3 +75,46 @@ pub async fn ask_request(
Err(e) => Json(e.into()),
}
}
pub async fn create_refund_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let other_transfer = match data.get_transfer_by_id(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if user.id != other_transfer.receiver || other_transfer.source != CoinTransferSource::Sale {
// only the receiver of the funds can issue a refund (atm)
return Json(Error::NotAllowed.into());
}
match data
.create_transfer(
&mut CoinTransfer::new(
other_transfer.receiver,
other_transfer.sender,
other_transfer.amount,
CoinTransferMethod::Transfer,
CoinTransferSource::Refund,
),
true,
)
.await
{
Ok(s) => Json(ApiReturn {
ok: true,
message: "Transfer created".to_string(),
payload: s.to_string(),
}),
Err(e) => Json(e.into()),
}
}

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::Error;
use tetratto_core::model::{
economy::{CoinTransferMethod, UserAd, UserAdSize},
Error,
};
use crate::{assets::initial_context, get_lang, get_user_from_token, State};
use super::{render_error, PaginatedQuery};
use serde::Deserialize;
/// `/wallet`
pub async fn wallet_request(
@ -60,7 +64,36 @@ pub async fn products_request(
}
};
let list = match data.0.get_products_by_user(user.id, 12, props.page).await {
let list = match data
.0
.get_products_by_user(
user.id,
12,
if props.page_set_id == 0 {
props.page
} else {
0
},
)
.await
{
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let ads_list = match data
.0
.get_ads_by_user(
user.id,
12,
if props.page_set_id == 1 {
props.page
} else {
0
},
)
.await
{
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
@ -69,7 +102,9 @@ pub async fn products_request(
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("list", &list);
context.insert("ads_list", &ads_list);
context.insert("page", &props.page);
context.insert("page_set_id", &props.page_set_id);
// return
Ok(Html(
@ -138,14 +173,154 @@ pub async fn product_request(
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let already_purchased = if product.single_use {
data.0
.get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id))
.await
.is_ok()
} else {
false
};
let applied_configurations_mapped: Vec<usize> =
user.applied_configurations.iter().map(|x| x.1).collect();
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("product", &product);
context.insert("owner", &owner);
context.insert("already_purchased", &already_purchased);
context.insert(
"applied_configurations_mapped",
&applied_configurations_mapped,
);
// return
Ok(Html(
data.1.render("economy/product.html", &context).unwrap(),
))
}
/// `/product/ad/{id}/edit`
pub async fn edit_ad_request(
jar: CookieJar,
Extension(data): Extension<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 {
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

@ -1,6 +1,7 @@
use super::{PaginatedQuery, render_error};
use crate::{
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State,
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token,
InnerState, State,
};
use axum::{
extract::{Path, Query},
@ -9,11 +10,14 @@ use axum::{
};
use crate::cookie::CookieJar;
use serde::Deserialize;
use tetratto_core::model::{
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS},
permissions::FinePermission,
requests::ActionType,
Error,
use tetratto_core::{
database::FullPost,
model::{
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS, User},
permissions::FinePermission,
requests::ActionType,
Error,
},
};
use std::fs::read_to_string;
use pathbufd::PathBufD;
@ -685,15 +689,12 @@ pub struct TimelineQuery {
pub responses_only: bool,
}
/// `/_swiss_army_timeline`
pub async fn swiss_army_timeline_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(req): Query<TimelineQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
async fn swiss_army_timeline(
data: &InnerState,
user: Option<User>,
req: &TimelineQuery,
jar: &CookieJar,
) -> std::result::Result<Vec<FullPost>, Html<String>> {
let ignore_users = crate::ignore_users_gen!(user, data);
let list = if req.stack_id != 0 {
@ -807,12 +808,41 @@ pub async fn swiss_army_timeline_request(
None
},
),
Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)),
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)),
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
}
};
Ok(list)
}
/// `/_swiss_army_timeline`
pub async fn swiss_army_timeline_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(mut req): Query<TimelineQuery>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
let mut empty_retries = 0; // how many times we've retried because of an empty list
let mut list = Vec::new();
while empty_retries < 2 {
list = match swiss_army_timeline(&data, user.clone(), &req, &jar).await {
Ok(x) => x,
Err(e) => return Err(e),
};
if list.len() == 0 && empty_retries != 2 {
empty_retries += 1;
req.page += 1;
} else {
break;
}
}
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;

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

@ -232,6 +232,7 @@ pub fn profile_context(
user: &Option<User>,
profile: &User,
communities: &Vec<Community>,
applied_configurations: Vec<String>,
is_self: bool,
is_following: bool,
is_following_you: bool,
@ -244,6 +245,7 @@ pub fn profile_context(
context.insert("is_following_you", &is_following_you);
context.insert("is_blocking", &is_blocking);
context.insert("warning_hash", &hash(profile.settings.warning.clone()));
context.insert("applied_configurations", &applied_configurations);
context.insert(
"is_supporter",
@ -376,6 +378,10 @@ pub async fn posts_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -492,6 +498,10 @@ pub async fn replies_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -604,6 +614,10 @@ pub async fn media_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -690,9 +704,13 @@ pub async fn shop_request(
context.insert("page", &props.page);
profile_context(
&mut context,
&Some(user),
&Some(user.clone()),
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
is_self,
is_following,
is_following_you,
@ -784,9 +802,13 @@ pub async fn outbox_request(
context.insert("page", &props.page);
profile_context(
&mut context,
&Some(user),
&Some(user.clone()),
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
is_self,
is_following,
is_following_you,
@ -896,6 +918,10 @@ pub async fn following_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -1005,6 +1031,10 @@ pub async fn followers_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,

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://"),
)
}

View file

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

View file

@ -0,0 +1,268 @@
use crate::model::{
auth::{User, UserWarning},
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, UserAd, UserAdSize},
permissions::FinePermission,
Error, Result,
};
use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
use tetratto_shared::unix_epoch_timestamp;
impl DataManager {
/// Get a [`UserAd`] from an SQL row.
pub(crate) fn get_ad_from_row(x: &PostgresRow) -> UserAd {
UserAd {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize,
upload_id: get!(x->3(i64)) as usize,
target: get!(x->4(String)),
last_charge_time: get!(x->5(i64)) as usize,
is_running: get!(x->6(i32)) as i8 == 1,
size: serde_json::from_str(&get!(x->7(String))).unwrap(),
}
}
auto_method!(get_ad_by_id(usize as i64)@get_ad_from_row -> "SELECT * FROM ads WHERE id = $1" --name="ad" --returns=UserAd --cache-key-tmpl="atto.ad:{}");
/// Get all ads by user.
///
/// # Arguments
/// * `id` - the ID of the user to fetch ads for
/// * `batch` - the limit of items in each page
/// * `page` - the page number
pub async fn get_ads_by_user(
&self,
id: usize,
batch: usize,
page: usize,
) -> Result<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())
}
/// Disable all ads by the given user.
///
/// # Arguments
/// * `id` - the ID of the user to kill ads from
pub async fn stop_all_ads_by_user(&self, id: usize) -> Result<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,
"UPDATE ads SET is_running = 0 WHERE owner = $1",
&[&(id as i64)],
|x| { Self::get_ad_from_row(x) }
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(res.unwrap())
}
/// Create a new ad in the database.
///
/// # Arguments
/// * `data` - a mock [`UserAd`] object to insert
pub async fn create_ad(&self, data: UserAd) -> Result<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 = 25;
/// The amount charged to a [`UserAd`] owner each time the ad is clicked.
pub const AD_CLICK_CHARGE: i32 = 2;
/// Get a random ad and check if the ad owner needs to be charged for this period.
pub async fn random_ad_charged(&self, size: UserAdSize) -> Result<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 {
if let Err(e) = self
.create_transfer(
&mut CoinTransfer::new(
ad.owner,
self.0.0.system_user,
Self::AD_RUN_CHARGE,
CoinTransferMethod::Transfer,
CoinTransferSource::AdCharge,
),
true,
)
.await
{
// boo user cannot afford to keep running their ads
self.stop_all_ads_by_user(ad.owner).await?;
return Err(e);
};
self.update_ad_last_charge_time(ad.id, now as i64).await?;
}
Ok(ad)
}
/// Handle a click on an ad from the given host.
///
/// Hosts are just the ID of the user that is embedding the ad on their page.
pub async fn ad_click(&self, host: usize, ad: usize, user: Option<User>) -> Result<String> {
let ad = self.get_ad_by_id(ad).await?;
if let Some(ref ua) = user {
if ua.id == host {
self.create_user_warning(
UserWarning::new(
ua.id,
self.0.0.system_user,
"Automated warning: do not click on ads on your own site! This incident has been reported.".to_string()
)
).await?;
return Ok(ad.target);
}
}
// create click transfer
if let Err(e) = self
.create_transfer(
&mut CoinTransfer::new(
ad.owner,
host,
Self::AD_CLICK_CHARGE,
CoinTransferMethod::Transfer,
CoinTransferSource::AdClick,
),
true,
)
.await
{
self.stop_all_ads_by_user(ad.owner).await?;
return Err(e);
}
// return
Ok(ad.target)
}
auto_method!(update_ad_is_running(i32)@get_ad_by_id:FinePermission::MANAGE_USERS; -> "UPDATE ads SET is_running = $1 WHERE id = $2" --cache-key-tmpl="atto.ad:{}");
auto_method!(update_ad_last_charge_time(i64) -> "UPDATE ads SET last_charge_time = $1 WHERE id = $2" --cache-key-tmpl="atto.ad:{}");
}

View file

@ -1,15 +1,15 @@
use super::common::NAME_REGEX;
use oiseau::cache::Cache;
use crate::model::auth::{
Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS,
};
use crate::model::moderation::AuditLogEntry;
use crate::model::oauth::AuthGrant;
use crate::model::permissions::SecondaryPermission;
use crate::model::{
Error, Result,
auth::{Token, User, UserSettings},
permissions::FinePermission,
permissions::{FinePermission, SecondaryPermission},
oauth::AuthGrant,
moderation::AuditLogEntry,
auth::{
Achievement, AchievementName, AchievementRarity, Notification, UserConnections,
ACHIEVEMENTS, AppliedConfigType,
},
};
use pathbufd::PathBufD;
use std::fs::{exists, remove_file};
@ -130,6 +130,7 @@ impl DataManager {
ban_expire: get!(x->30(i64)) as usize,
coins: get!(x->31(i32)),
checkouts: serde_json::from_str(&get!(x->32(String)).to_string()).unwrap(),
applied_configurations: serde_json::from_str(&get!(x->33(String)).to_string()).unwrap(),
}
}
@ -286,7 +287,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33)",
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34)",
params![
&(data.id as i64),
&(data.created as i64),
@ -321,6 +322,7 @@ impl DataManager {
&(data.ban_expire as i64),
&(data.coins as i32),
&serde_json::to_string(&data.checkouts).unwrap(),
&serde_json::to_string(&data.applied_configurations).unwrap(),
]
);
@ -538,6 +540,68 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
// delete transfers
let res = execute!(
&conn,
"DELETE FROM transfers WHERE sender = $1 OR receiver = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete products
let res = execute!(
&conn,
"DELETE FROM products WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete domains
let res = execute!(
&conn,
"DELETE FROM domains WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete services
let res = execute!(
&conn,
"DELETE FROM services WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete letters
let res = execute!(
&conn,
"DELETE FROM letters WHERE owner = $1",
&[&(id as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete ads
let res = execute!(&conn, "DELETE FROM ads WHERE owner = $1", &[&(id as i64)]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete user follows... individually since it requires updating user counts
for follow in self.get_userfollows_by_receiver_all(id).await? {
self.delete_userfollow(follow.id, &user, true).await?;
@ -1036,6 +1100,38 @@ impl DataManager {
Ok((totp.get_secret_base32(), qr, recovery))
}
/// Get all applied configurations as a vector of strings from the given user.
pub async fn get_applied_configurations(&self, user: &User) -> Result<Vec<String>> {
let mut out = Vec::new();
for config in &user.applied_configurations {
let product = self.get_product_by_id(config.1).await?;
let owner = self.get_user_by_id_with_void(product.owner).await?;
if config.0 == AppliedConfigType::StyleSnippet
&& !owner.permissions.check(FinePermission::SUPPORTER)
{
out.push(format!(
"<script>console.warn(\"{} has stopped their supporter subscription, so this applied configuration no longer works.\");</script>",
owner.username
));
continue;
}
out.push(match config.0 {
AppliedConfigType::StyleSnippet => {
format!(
"<style>{}</style>",
product.data.replace("<", "&lt;").replace(">", "&gt;")
)
}
})
}
Ok(out)
}
pub async fn cache_clear_user(&self, user: &User) {
self.0.1.remove(format!("atto.user:{}", user.id)).await;
self.0
@ -1064,6 +1160,7 @@ impl DataManager {
auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_coins(i32)@get_user_by_id -> "UPDATE users SET coins = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_checkouts(Vec<String>)@get_user_by_id -> "UPDATE users SET checkouts = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_applied_configurations(Vec<(AppliedConfigType, usize)>)@get_user_by_id -> "UPDATE users SET applied_configurations = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User);
auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);

View file

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

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_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");

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

@ -7,5 +7,8 @@ CREATE TABLE IF NOT EXISTS products (
method TEXT NOT NULL,
on_sale INT NOT NULL,
price INT NOT NULL,
stock INT NOT NULL
stock INT NOT NULL,
single_use INT NOT NULL,
data TEXT NOT NULL,
uploads TEXT NOT NULL
)

View file

@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS transfers (
receiver BIGINT NOT NULL,
amount INT NOT NULL,
is_pending INT NOT NULL,
method TEXT NOT NULL
method TEXT NOT NULL,
source TEXT NOT NULL
)

View file

@ -31,5 +31,6 @@ CREATE TABLE IF NOT EXISTS users (
is_deactivated INT NOT NULL,
ban_expire BIGINT NOT NULL,
coins INT NOT NULL,
checkouts TEXT NOT NULL
checkouts TEXT NOT NULL,
applied_configurations TEXT NOT NULL
)

View file

@ -45,3 +45,23 @@ ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '"Null"';
-- users checkouts
ALTER TABLE users
ADD COLUMN IF NOT EXISTS checkouts TEXT DEFAULT '[]';
-- products single_use
ALTER TABLE products
ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1;
-- transfers source
ALTER TABLE transfers
ADD COLUMN IF NOT EXISTS source TEXT DEFAULT '"General"';
-- products data
ALTER TABLE products
ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '';
-- users applied_configurations
ALTER TABLE users
ADD COLUMN IF NOT EXISTS applied_configurations TEXT DEFAULT '[]';
-- products uploads
ALTER TABLE products
ADD COLUMN IF NOT EXISTS uploads TEXT DEFAULT '{}';

View file

@ -1,3 +1,4 @@
mod ads;
pub mod app_data;
mod apps;
mod audit_log;
@ -39,3 +40,4 @@ mod userfollows;
pub use drivers::DataManager;
pub use common::NAME_REGEX;
pub use posts::FullPost;

View file

@ -2221,7 +2221,9 @@ impl DataManager {
// decr parent comment count
if let Some(replying_to) = y.replying_to {
self.decr_post_comments(replying_to).await.unwrap();
if replying_to != 0 {
self.decr_post_comments(replying_to).await.unwrap();
}
}
// decr user post count

View file

@ -1,6 +1,9 @@
use crate::model::{
auth::User,
economy::{CoinTransfer, CoinTransferMethod, Product, ProductFulfillmentMethod},
economy::{
CoinTransfer, CoinTransferMethod, CoinTransferSource, Product, ProductFulfillmentMethod,
ProductUploads,
},
mail::Letter,
permissions::FinePermission,
Error, Result,
@ -21,6 +24,9 @@ impl DataManager {
on_sale: get!(x->6(i32)) as i8 == 1,
price: get!(x->7(i32)),
stock: get!(x->8(i32)),
single_use: get!(x->9(i32)) as i8 == 1,
data: get!(x->10(String)),
uploads: serde_json::from_str(&get!(x->11(String))).unwrap(),
}
}
@ -57,7 +63,7 @@ impl DataManager {
Ok(res.unwrap())
}
const MAXIMUM_FREE_PRODUCTS: usize = 5;
const MAXIMUM_FREE_PRODUCTS: usize = 10;
/// Create a new product in the database.
///
@ -103,7 +109,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
params![
&(data.id as i64),
&(data.created as i64),
@ -113,7 +119,10 @@ impl DataManager {
&serde_json::to_string(&data.method).unwrap(),
&{ if data.on_sale { 1 } else { 0 } },
&data.price,
&(data.stock as i32)
&(data.stock as i32),
&{ if data.single_use { 1 } else { 0 } },
&data.data,
&serde_json::to_string(&data.uploads).unwrap(),
]
);
@ -131,11 +140,28 @@ impl DataManager {
customer: &mut User,
) -> Result<CoinTransfer> {
let product = self.get_product_by_id(product).await?;
// handle single_use product
if product.single_use {
if self
.get_transfer_by_sender_method(
customer.id,
CoinTransferMethod::Purchase(product.id),
)
.await
.is_ok()
{
return Err(Error::MiscError("You already own this product".to_string()));
}
}
// ...
let mut transfer = CoinTransfer::new(
customer.id,
product.owner,
product.price,
CoinTransferMethod::Purchase(product.id),
CoinTransferSource::Sale,
);
if !product.stock.is_negative() {
@ -198,6 +224,21 @@ If your product is a purchase of goods or services, please be sure to fulfill th
// return
Ok(transfer)
}
ProductFulfillmentMethod::ProfileStyle => {
// pretty much an automail without the message
self.create_transfer(&mut transfer, true).await?;
self.create_letter(Letter::new(
self.0.0.system_user,
vec![customer.id],
format!("Thank you for purchasing \"{}\"", product.title),
"You've purchased a CSS snippet which can be applied to your profile through the product's page!".to_string(),
0,
))
.await?;
Ok(transfer)
}
}
}
@ -209,6 +250,15 @@ If your product is a purchase of goods or services, please be sure to fulfill th
return Err(Error::NotAllowed);
}
// remove uploads
for upload in product.uploads.thumbnails {
self.delete_upload(upload).await?;
}
if product.uploads.reward != 0 {
self.delete_upload(product.uploads.reward).await?;
}
// ...
let conn = match self.0.connect().await {
Ok(c) => c,
@ -231,6 +281,9 @@ If your product is a purchase of goods or services, please be sure to fulfill th
auto_method!(update_product_price(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_on_sale(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET on_sale = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_method(ProductFulfillmentMethod)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET method = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_single_use(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET single_use = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_data(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET data = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_uploads(ProductUploads)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET uploads = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_stock(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET stock = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(incr_product_stock() -> "UPDATE products SET stock = stock + 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --incr);

View file

@ -29,7 +29,14 @@ impl DataManager {
.get(format!("atto.request:{}:{}", id, linked_asset))
.await
{
return Ok(serde_json::from_str(&cached).unwrap());
if let Ok(x) = serde_json::from_str(&cached) {
return Ok(x);
} else {
self.0
.1
.remove(format!("atto.request:{}:{}", id, linked_asset))
.await;
}
}
let conn = match self.0.connect().await {

View file

@ -1,11 +1,11 @@
use std::collections::HashMap;
use crate::model::{
Error, Result,
economy::{CoinTransferMethod, Product, CoinTransfer},
auth::{Notification, User},
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, Product},
Error, Result,
};
use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow};
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
impl DataManager {
/// Get a [`CoinTransfer`] from an SQL row.
@ -18,6 +18,7 @@ impl DataManager {
amount: get!(x->4(i32)),
is_pending: get!(x->5(i32)) as i8 == 1,
method: serde_json::from_str(&get!(x->6(String))).unwrap(),
source: serde_json::from_str(&get!(x->7(String))).unwrap(),
}
}
@ -27,16 +28,13 @@ impl DataManager {
pub async fn fill_transfers(
&self,
list: Vec<CoinTransfer>,
) -> Result<Vec<(usize, usize, i32, User, User, Option<Product>, bool)>> {
) -> Result<Vec<(User, User, Option<Product>, CoinTransfer)>> {
let mut out = Vec::new();
let mut seen_users: HashMap<usize, User> = HashMap::new();
let mut seen_products: HashMap<usize, Product> = HashMap::new();
for transfer in list {
out.push((
transfer.id,
transfer.created,
transfer.amount,
if let Some(user) = seen_users.get(&transfer.sender) {
user.to_owned()
} else {
@ -54,16 +52,20 @@ impl DataManager {
match transfer.method {
CoinTransferMethod::Transfer => None,
CoinTransferMethod::Purchase(id) => {
Some(if let Some(product) = seen_products.get(&id) {
product.to_owned()
if let Some(product) = seen_products.get(&id) {
Some(product.to_owned())
} else {
let product = self.get_product_by_id(id).await?;
seen_products.insert(product.id, product.clone());
product
})
match self.get_product_by_id(id).await {
Ok(product) => {
seen_products.insert(product.id, product.clone());
Some(product)
}
Err(_) => None,
}
}
}
},
transfer.is_pending,
transfer,
));
}
@ -101,6 +103,35 @@ impl DataManager {
Ok(res.unwrap())
}
/// Get a transfer by user and method.
///
/// # Arguments
/// * `id` - the ID of the user to fetch transfers for
/// * `method` - the transfer method
pub async fn get_transfer_by_sender_method(
&self,
id: usize,
method: CoinTransferMethod,
) -> Result<CoinTransfer> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(
&conn,
"SELECT * FROM transfers WHERE sender = $1 AND method = $2 LIMIT 1",
params![&(id as i64), &serde_json::to_string(&method).unwrap()],
|x| { Ok(Self::get_transfer_from_row(x)) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("transfer".to_string()));
}
Ok(res.unwrap())
}
/// Create a new transfer in the database.
///
/// # Arguments
@ -129,6 +160,16 @@ impl DataManager {
}
self.update_user_coins(receiver.id, receiver.coins).await?;
// handle refund notification
if data.source == CoinTransferSource::Refund {
self.create_notification(Notification::new(
"A coin refund has been issued to your account!".to_string(),
"You've been issued a refund for a prior purchase. The product will remain in your account, but your coins have been returned.".to_string(),
receiver.id,
))
.await?;
}
} else {
// we haven't applied the transfer, so this must be pending
data.is_pending = true;
@ -142,7 +183,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO transfers VALUES ($1, $2, $3, $4, $5, $6, $7)",
"INSERT INTO transfers VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
params![
&(data.id as i64),
&(data.created as i64),
@ -151,6 +192,7 @@ impl DataManager {
&data.amount,
&{ if data.is_pending { 1 } else { 0 } },
&serde_json::to_string(&data.method).unwrap(),
&serde_json::to_string(&data.source).unwrap(),
]
);

View file

@ -101,11 +101,20 @@ pub struct User {
/// already applied this purchase.
#[serde(default)]
pub checkouts: Vec<String>,
/// The IDs of products to be applied to the user's profile.
#[serde(default)]
pub applied_configurations: Vec<(AppliedConfigType, usize)>,
}
pub type UserConnections =
HashMap<ConnectionService, (ExternalConnectionInfo, ExternalConnectionData)>;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum AppliedConfigType {
/// An HTML `<style>` snippet.
StyleSnippet,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum ThemePreference {
Auto,
@ -362,6 +371,9 @@ pub struct UserSettings {
/// If your profile has the "Shop" tab enabled.
#[serde(default)]
pub enable_shop: bool,
/// Hide all badges from your username (everywhere but on profile).
#[serde(default)]
pub hide_username_badges: bool,
}
fn mime_avif() -> String {
@ -414,6 +426,7 @@ impl User {
ban_expire: 0,
coins: 0,
checkouts: Vec::new(),
applied_configurations: Vec::new(),
}
}

View file

@ -10,6 +10,33 @@ pub enum ProductFulfillmentMethod {
///
/// This will leave the [`CoinTransfer`] pending until you send this mail.
ManualMail,
/// A CSS snippet which can be applied to user profiles.
///
/// Only supporters can create products like this.
ProfileStyle,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ProductUploads {
/// Promotional thumbnails shown on the product page.
///
/// Maximum of 4 with a maximum upload size of 2 MiB.
#[serde(default)]
pub thumbnails: Vec<usize>,
/// Reward given to users through active configurations after they purchase the product.
//
// Maximum upload size of 4 MiB.
#[serde(default)]
pub reward: usize,
}
impl Default for ProductUploads {
fn default() -> Self {
Self {
thumbnails: Vec::new(),
reward: 0,
}
}
}
#[derive(Clone, Serialize, Deserialize)]
@ -29,6 +56,15 @@ pub struct Product {
///
/// A negative stock means the product has unlimited stock.
pub stock: i32,
/// If this product is limited to one purchase per person.
#[serde(default)]
pub single_use: bool,
/// Data for this product. Only used by snippets.
#[serde(default)]
pub data: String,
/// Uploads for this product.
#[serde(default)]
pub uploads: ProductUploads,
}
impl Product {
@ -44,6 +80,9 @@ impl Product {
on_sale: false,
price: 0,
stock: 0,
single_use: true,
data: String::new(),
uploads: ProductUploads::default(),
}
}
}
@ -55,6 +94,22 @@ pub enum CoinTransferMethod {
Purchase(usize),
}
#[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum CoinTransferSource {
/// An unknown source, such as a transfer request.
General,
/// A product sale.
Sale,
/// A purchase of coins through Stripe.
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)]
pub struct CoinTransfer {
pub id: usize,
@ -64,11 +119,18 @@ pub struct CoinTransfer {
pub amount: i32,
pub is_pending: bool,
pub method: CoinTransferMethod,
pub source: CoinTransferSource,
}
impl CoinTransfer {
/// Create a new [`CoinTransfer`].
pub fn new(sender: usize, receiver: usize, amount: i32, method: CoinTransferMethod) -> Self {
pub fn new(
sender: usize,
receiver: usize,
amount: i32,
method: CoinTransferMethod,
source: CoinTransferSource,
) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp(),
@ -77,6 +139,7 @@ impl CoinTransfer {
amount,
is_pending: false,
method,
source,
}
}
@ -90,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,
}
}
}