Compare commits
20 commits
Author | SHA1 | Date | |
---|---|---|---|
83971b3d20 | |||
9a5236bc1b | |||
59bccd9474 | |||
2cb7d08ddc | |||
46b3e66cd4 | |||
ba319130d2 | |||
bcee3f7763 | |||
59378a4447 | |||
7f0cb1f2a1 | |||
5a9160f612 | |||
7a31dcbd9b | |||
815e730fc0 | |||
9ef9b9e579 | |||
e145449bc7 | |||
fdaae8d977 | |||
95cb889080 | |||
077e9252e3 | |||
98426d0989 | |||
a08552338b | |||
e5e6d5cddb |
62 changed files with 2589 additions and 173 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -201,8 +201,7 @@ p {
|
|||
.name {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
/* overflow-wrap: break-word; */
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
(div ("id" "toast_zone"))
|
||||
|
||||
; ads
|
||||
(script ("src" "/js/ads.js?v=tetratto-{{ random_cache_breaker }}"))
|
||||
(script
|
||||
(text "TetrattoAds.init();"))
|
||||
|
||||
; large text
|
||||
(text "{% if user and user.settings.large_text -%}")
|
||||
(style
|
||||
|
@ -76,6 +81,8 @@
|
|||
return;
|
||||
}
|
||||
|
||||
TetrattoAds.render_ads(\"{{ config.system_user }}\", \"\");
|
||||
|
||||
atto.disconnect_observers();
|
||||
atto.remove_false_options();
|
||||
atto.clean_date_codes();
|
||||
|
|
|
@ -182,7 +182,7 @@
|
|||
(text "{%- endif %} {%- endif %}")
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))))
|
||||
(str (text "communities:action.create")))))))
|
||||
(text "{% if not quoting -%}")
|
||||
(script
|
||||
(text "async function create_post_from_form(e) {
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
(span
|
||||
(text "Make this a forum community")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}")
|
||||
(div
|
||||
("class" "card_nest w_full")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 %}")
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
("placeholder" "redirect URL")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
|
||||
; app listing
|
||||
(div
|
||||
|
|
63
crates/app/src/public/html/economy/ad.lisp
Normal file
63
crates/app/src/public/html/economy/ad.lisp
Normal 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%;
|
||||
}"))))
|
|
@ -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,11 +167,20 @@
|
|||
(icon (text "package-check"))
|
||||
(b
|
||||
(str (text "economy:label.fulfillment_style"))))
|
||||
(form
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(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")
|
||||
|
@ -153,7 +191,7 @@
|
|||
("name" "use_automail")
|
||||
("class" "w_content")
|
||||
("oninput" "mirror_use_automail()")
|
||||
("checked" "{% if product.method != \"ManualMail\" -%} true {%- else -%} false {%- endif %}"))
|
||||
("checked" "{% if is_automail -%} true {%- else -%} false {%- endif %}"))
|
||||
(span
|
||||
(str (text "economy:label.use_automail"))))
|
||||
(div
|
||||
|
@ -165,17 +203,122 @@
|
|||
("name" "automail_message")
|
||||
("id" "automail_message")
|
||||
("placeholder" "automail_message")
|
||||
(text "{% if product.method != \"ManualMail\" -%} {{ product.method.AutoMail }} {%- endif %}")))
|
||||
(button (str (text "general:action.save")))))
|
||||
(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"))))))
|
||||
|
||||
(div
|
||||
("class" "flex gap_2")
|
||||
(a
|
||||
("class" "button secondary")
|
||||
("href" "/product/{{ product.id }}")
|
||||
(icon (text "arrow-left"))
|
||||
(str (text "general:action.back"))))
|
||||
(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 %}")
|
||||
|
|
97
crates/app/src/public/html/economy/edit_ad.lisp
Normal file
97
crates/app/src/public/html/economy/edit_ad.lisp
Normal file
|
@ -0,0 +1,97 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Manage advertisement - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(icon (text "link"))
|
||||
(b
|
||||
(text "{{ ad.target }}")))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "event.preventDefault(); update_is_running_from_form(event.target.is_running.checked)")
|
||||
(object ("class" "tetratto_ad") ("data-ad-size" "{{ ad.size }}") ("data-noclick" "true") ("data-ad-id" "{{ ad.id }}"))
|
||||
(ul
|
||||
(li
|
||||
(text "{% if ad.last_charge_time != 0 -%}")
|
||||
(text "Last charge: ") (span ("class" "date") (text "{{ ad.last_charge_time }}"))
|
||||
(text "{% else %}")
|
||||
(text "No previous charges")
|
||||
(text "{%- endif %}")))
|
||||
(div ("class" "squig"))
|
||||
(p (text "Each day your ad is viewed, you'll be charged 25 coins. This charge only applies to the very first view of the day."))
|
||||
(p (text "Additionally, you'll be charged 2 coins per click on your ad. This fee will be paid to the user which hosts the site your ad was shown on."))
|
||||
(p (text "Each of these transfers will be shown in your wallet's transfer table as either \"AdClick\" or \"AdCharge\"."))
|
||||
(label
|
||||
("for" "is_running")
|
||||
("class" "flex items_center gap_2")
|
||||
(input
|
||||
("type" "checkbox")
|
||||
("id" "is_running")
|
||||
("name" "is_running")
|
||||
("class" "w_content")
|
||||
("checked" "{{ ad.is_running }}"))
|
||||
(span
|
||||
(str (text "economy:label.running"))))
|
||||
(button (str (text "general:action.save")))))
|
||||
|
||||
(div
|
||||
("class" "flex gap_2")
|
||||
(a
|
||||
("class" "button secondary")
|
||||
("href" "/products")
|
||||
(icon (text "arrow-left"))
|
||||
(str (text "general:action.back")))
|
||||
|
||||
(button
|
||||
("class" "lowered red")
|
||||
("onclick" "delete_ad()")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))
|
||||
|
||||
(script
|
||||
(text "async function update_is_running_from_form(is_running) {
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
fetch(\"/api/v1/ads/{{ ad.id }}/running\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
is_running,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function delete_ad() {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/ads/{{ ad.id }}\", {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};"))
|
||||
(text "{% endblock %}")
|
|
@ -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 %}")
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "1024")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
|
||||
; product listing
|
||||
(div
|
||||
|
@ -56,7 +56,105 @@
|
|||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(text "{% for item in list %} {{ components::product_listing_card(product=item, edit=true) }} {% endfor %}")
|
||||
(text "{{ components::pagination(page=page, items=list|length) }}"))))
|
||||
; selective pagination
|
||||
(text "{% if page_set_id == 0 -%}")
|
||||
(text "{{ components::pagination(page=page, items=list|length) }}")
|
||||
(text "{% else %}")
|
||||
(text "{{ components::pagination(page=0, items=list|length) }}")
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(text "{% if config.enable_user_ads -%}")
|
||||
(div ("class" "squig") ("style" "--background: var(--color-surface)"))
|
||||
|
||||
; create new ad
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(str (text "economy:label.create_new_ad"))))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "create_ad_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "target")
|
||||
(str (text "economy:label.target")))
|
||||
(input
|
||||
("type" "url")
|
||||
("name" "target")
|
||||
("id" "target")
|
||||
("placeholder" "target url")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "128")))
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "file")
|
||||
(str (text "economy:label.image")))
|
||||
(input
|
||||
("id" "file")
|
||||
("name" "file")
|
||||
("type" "file")
|
||||
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
|
||||
("required" "")
|
||||
("class" "w_content")))
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "size_base")
|
||||
(str (text "economy:label.size_base")))
|
||||
(select
|
||||
("id" "size_base")
|
||||
("name" "size_base")
|
||||
(option ("value" "Leaderboard") (text "Leaderboard (720x90)"))
|
||||
(option ("value" "Billboard") (text "Billboard (970x250)"))
|
||||
(option ("value" "Skyscraper") (text "Skyscraper (160x600)"))
|
||||
(option ("value" "MediumRectangle") (text "Medium rectangle (300x250)"))
|
||||
(option ("value" "MobileLeaderboard") (text "Mobile leaderboard (320x50, mobile only)"))))
|
||||
(button
|
||||
(str (text "communities:action.create")))))
|
||||
|
||||
; ad listing
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex items_center gap_2")
|
||||
(icon (text "images"))
|
||||
(str (text "economy:label.my_ads")))
|
||||
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(text "{% for item in ads_list %} {{ components::ad_listing_card(ad=item) }} {% endfor %}")
|
||||
; selective pagination
|
||||
(text "{% if page_set_id == 1 -%}")
|
||||
(text "{{ components::pagination(page=page, items=ads_list|length, key=\"&page_set_id=1\") }}")
|
||||
(text "{% else %}")
|
||||
(text "{{ components::pagination(page=0, items=ads_list|length, key=\"&page_set_id=1\") }}")
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex items_center gap_2")
|
||||
(icon (text "code"))
|
||||
(str (text "economy:label.embed_ads_on_my_site")))
|
||||
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(p (text "You can embed the advertising network into your site to earn a (coin) commission from clicks."))
|
||||
(p (text "Place the following into your site's HTML:"))
|
||||
(pre (code (text "<script src=\"{{ config.host }}\"/js/ads.js\"></script>
|
||||
<script>TetrattoAds.init(); TetrattoAds.render_ads(\"{{ user.id }}\", \"{{ config.host }}\")</script>")))
|
||||
(p (text "After you've done that, you can place your ads like so:"))
|
||||
(pre (code (text "<object class=\"tetratto_ad\" data-ad-size=\"$size$\"></object>")))
|
||||
(p
|
||||
(text "In the above example, replace \"$size$\" with a size from ")
|
||||
(a ("href" "https://tetratto.com/reference/tetratto/model/economy/enum.UserAdSize.html") (text "here"))
|
||||
(text " keep in mind that the name of a size must be in title case. That means it's \"Leaderboard\", not \"leaderboard\"."))))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(script
|
||||
(text "async function create_product_from_form(e) {
|
||||
|
@ -87,5 +185,43 @@
|
|||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function create_ad_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"products::create\"]);
|
||||
|
||||
// create body
|
||||
const body = new FormData();
|
||||
|
||||
for (const file of e.target.file.files) {
|
||||
body.append(file.name, file);
|
||||
}
|
||||
|
||||
body.append(
|
||||
\"body\",
|
||||
JSON.stringify({
|
||||
target: e.target.target.value,
|
||||
size: e.target.size_base.selectedOptions[0].value,
|
||||
}),
|
||||
);
|
||||
|
||||
// ...
|
||||
fetch(\"/api/v1/ads\", {
|
||||
method: \"POST\",
|
||||
body,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
e.target.reset();
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}"))
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -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 %}")
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(text "{% else %}")
|
||||
(text "{{ components::developer_pass_ad(body=\"Get a developer pass to create forges!\") }}")
|
||||
(text "{%- endif %}")
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
(option ("value" "{{ tld }}") (text ".{{ tld|lower }}"))
|
||||
(text "{%- endfor %}")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))
|
||||
(str (text "communities:action.create")))
|
||||
|
||||
(details
|
||||
(summary
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card_nest w_full")
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
("required" "")
|
||||
("minlength" "16")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}")))))
|
||||
(str (text "communities:action.create"))))))
|
||||
|
||||
(script
|
||||
(text "function create_report_from_form(e) {
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
("class" "lowered small")
|
||||
(text "{{ icon \"plus\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(text "{% for item in items %}")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "4096")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
|
|
|
@ -88,7 +88,7 @@
|
|||
("class" "flex gap_2")
|
||||
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}")))))
|
||||
(str (text "communities:action.create"))))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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\"],
|
||||
[
|
||||
[
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card_nest w_full")
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", forum_posts=\"/all/forum_posts\") }} {% if not user -%}")
|
||||
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", forum_posts=\"/all/forum_posts\") }}")
|
||||
(text "{{ components::advertisement(size=\"Leaderboard\") }}")
|
||||
(text "{% if not user -%}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
|
|
65
crates/app/src/public/js/ads.js
Normal file
65
crates/app/src/public/js/ads.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
globalThis.TetrattoAds = {
|
||||
AD_SIZES: {
|
||||
Billboard: [970, 250],
|
||||
Leaderboard: [720, 90],
|
||||
Skyscraper: [160, 600],
|
||||
MediumRectangle: [300, 250],
|
||||
MobileLeaderboard: [320, 50],
|
||||
},
|
||||
IS_MOBILE: window.innerWidth <= 900 && window.innerHeight <= 900,
|
||||
};
|
||||
|
||||
globalThis.TetrattoAds.init = () => {
|
||||
const styles = document.createElement("style");
|
||||
styles.id = "tetratto_ads_css";
|
||||
styles.setAttribute("data-turbo-permanent", "true");
|
||||
|
||||
styles.innerHTML = `.tetratto_ad {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.tetratto_ad,
|
||||
.tetratto_ad iframe {
|
||||
max-width: 100%;
|
||||
background: transparent;
|
||||
}`;
|
||||
|
||||
document.head.appendChild(styles);
|
||||
};
|
||||
|
||||
globalThis.TetrattoAds.render_ads = (
|
||||
host_id = 0,
|
||||
tetratto = "https://tetratto.com",
|
||||
) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll(".tetratto_ad"),
|
||||
)) {
|
||||
if (element.children.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
let size = element.getAttribute("data-ad-size") || "MediumRectangle";
|
||||
|
||||
if (size === "Leaderboard" && TetrattoAds.IS_MOBILE) {
|
||||
size = "MobileLeaderboard";
|
||||
}
|
||||
|
||||
const size_px = TetrattoAds.AD_SIZES[size];
|
||||
|
||||
const noclick =
|
||||
element.getAttribute("data-noclick") === "true" || false;
|
||||
const ad_id = element.getAttribute("data-ad-id");
|
||||
|
||||
iframe.src = `${tetratto}/adn/${ad_id ? ad_id : "random"}?size=${size}&host=${host_id}&noclick=${noclick}`;
|
||||
iframe.setAttribute("frameborder", "0");
|
||||
iframe.loading = "lazy";
|
||||
|
||||
iframe.style.width = `${size_px[0]}px`;
|
||||
iframe.style.height = `${size_px[1]}px`;
|
||||
|
||||
element.appendChild(iframe);
|
||||
}
|
||||
};
|
146
crates/app/src/routes/api/v1/ads.rs
Normal file
146
crates/app/src/routes/api/v1/ads.rs
Normal 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),
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ pub async fn get_request(
|
|||
Body::from(read_image(PathBufD::current().extend(&[
|
||||
data.0.0.dirs.media.as_str(),
|
||||
"images",
|
||||
"default-avatar.svg",
|
||||
"default-banner.svg",
|
||||
]))),
|
||||
));
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ pub async fn get_request(
|
|||
Body::from(read_image(PathBufD::current().extend(&[
|
||||
data.0.0.dirs.media.as_str(),
|
||||
"images",
|
||||
"default-avatar.svg",
|
||||
"default-banner.svg",
|
||||
]))),
|
||||
));
|
||||
}
|
||||
|
|
|
@ -21,3 +21,4 @@ serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
|
|||
serve_asset!(carp_js_request: CARP_JS("text/javascript"));
|
||||
serve_asset!(proto_links_request: PROTO_LINKS_JS("text/javascript"));
|
||||
serve_asset!(app_sdk_request: APP_SDK_JS("text/javascript"));
|
||||
serve_asset!(ads_request: ADS_JS("text/javascript"));
|
||||
|
|
|
@ -22,6 +22,7 @@ pub fn routes(config: &Config) -> Router {
|
|||
.route("/js/carp.js", get(assets::carp_js_request))
|
||||
.route("/js/proto_links.js", get(assets::proto_links_request))
|
||||
.route("/js/app_sdk.js", get(assets::app_sdk_request))
|
||||
.route("/js/ads.js", get(assets::ads_request))
|
||||
.nest_service(
|
||||
"/public",
|
||||
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
|
||||
|
|
|
@ -4,9 +4,13 @@ use axum::{
|
|||
Extension,
|
||||
};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::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()),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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},
|
||||
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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -9,9 +9,9 @@ pub fn color_escape(color: &str) -> String {
|
|||
.replace(">", "%gt;")
|
||||
.replace("}", "")
|
||||
.replace("{", "")
|
||||
.replace("url(\"", "url(\"/api/v0/util/ext/image?img=")
|
||||
.replace("url('", "url('/api/v0/util/ext/image?img=")
|
||||
.replace("url(https://", "url(/api/v0/util/ext/image?img=https://"),
|
||||
.replace("url(\"", "url(\"/api/v1/util/proxy?url=")
|
||||
.replace("url('", "url('/api/v1/util/proxy?url=")
|
||||
.replace("url(https://", "url(/api/v1/util/proxy?url=https://"),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -344,6 +344,9 @@ pub struct Config {
|
|||
/// A list of banned content in posts.
|
||||
#[serde(default)]
|
||||
pub banned_data: Vec<StringBan>,
|
||||
/// If user ads are enabled.
|
||||
#[serde(default)]
|
||||
pub enable_user_ads: bool,
|
||||
}
|
||||
|
||||
fn default_name() -> String {
|
||||
|
@ -463,6 +466,7 @@ impl Default for Config {
|
|||
stripe: None,
|
||||
manuals: default_manuals(),
|
||||
banned_data: default_banned_data(),
|
||||
enable_user_ads: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
268
crates/core/src/database/ads.rs
Normal file
268
crates/core/src/database/ads.rs
Normal 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:{}");
|
||||
}
|
|
@ -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("<", "<").replace(">", ">")
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
@ -46,6 +46,7 @@ impl DataManager {
|
|||
execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_TRANSFERS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_ADS).unwrap();
|
||||
|
||||
for x in common::VERSION_MIGRATIONS.split(";") {
|
||||
execute!(&conn, x).unwrap();
|
||||
|
|
|
@ -34,3 +34,4 @@ pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql"
|
|||
pub const CREATE_TABLE_LETTERS: &str = include_str!("./sql/create_letters.sql");
|
||||
pub const CREATE_TABLE_TRANSFERS: &str = include_str!("./sql/create_transfers.sql");
|
||||
pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql");
|
||||
pub const CREATE_TABLE_ADS: &str = include_str!("./sql/create_ads.sql");
|
||||
|
|
10
crates/core/src/database/drivers/sql/create_ads.sql
Normal file
10
crates/core/src/database/drivers/sql/create_ads.sql
Normal file
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE IF NOT EXISTS ads (
|
||||
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
upload_id BIGINT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
last_charge_time BIGINT NOT NULL,
|
||||
is_running INT NOT NULL,
|
||||
size TEXT NOT NULL
|
||||
)
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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 '{}';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -2221,8 +2221,10 @@ impl DataManager {
|
|||
|
||||
// decr parent comment count
|
||||
if let Some(replying_to) = y.replying_to {
|
||||
if replying_to != 0 {
|
||||
self.decr_post_comments(replying_to).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// decr user post count
|
||||
let owner = self.get_user_by_id(y.owner).await?;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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?;
|
||||
match self.get_product_by_id(id).await {
|
||||
Ok(product) => {
|
||||
seen_products.insert(product.id, product.clone());
|
||||
product
|
||||
})
|
||||
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(),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue