add: stripe integration

This commit is contained in:
trisua 2025-05-05 19:38:01 -04:00
parent 2fa5a4dc1f
commit 1d120555a0
31 changed files with 1137 additions and 122 deletions

View file

@ -40,3 +40,10 @@ redis = { version = "0.30.0", features = [
"tokio-comp",
], optional = true }
tower_governor = "0.7.0"
async-stripe = { version = "0.41.0", features = [
"events",
"checkout",
"webhook-events",
"billing",
"runtime-tokio-hyper",
] }

View file

@ -8,7 +8,10 @@ use std::{
use tera::Context;
use tetratto_core::{
config::Config,
model::auth::{DefaultTimelineChoice, User},
model::{
auth::{DefaultTimelineChoice, User},
permissions::FinePermission,
},
PUBSUB_ENABLED,
};
use tetratto_l10n::LangFile;
@ -36,7 +39,6 @@ pub const ROOT: &str = include_str!("./public/html/root.html");
pub const MACROS: &str = include_str!("./public/html/macros.html");
pub const COMPONENTS: &str = include_str!("./public/html/components.html");
pub const MISC_INDEX: &str = include_str!("./public/html/misc/index.html");
pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.html");
pub const MISC_NOTIFICATIONS: &str = include_str!("./public/html/misc/notifications.html");
pub const MISC_MARKDOWN: &str = include_str!("./public/html/misc/markdown.html");
@ -232,8 +234,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"macros.html"(crate::assets::MACROS) --config=config);
write_template!(html_path->"components.html"(crate::assets::COMPONENTS) --config=config);
write_template!(html_path->"misc/index.html"(crate::assets::MISC_INDEX) -d "misc" --config=config);
write_template!(html_path->"misc/error.html"(crate::assets::MISC_ERROR) --config=config);
write_template!(html_path->"misc/error.html"(crate::assets::MISC_ERROR) -d "misc" --config=config);
write_template!(html_path->"misc/notifications.html"(crate::assets::MISC_NOTIFICATIONS) --config=config);
write_template!(html_path->"misc/markdown.html"(crate::assets::MISC_MARKDOWN) --config=config);
write_template!(html_path->"misc/requests.html"(crate::assets::MISC_REQUESTS) --config=config);
@ -312,6 +313,9 @@ pub(crate) async fn init_dirs(config: &Config) {
create_dir_if_not_exists!(
&PathBufD::current().extend(&[config.dirs.media.as_str(), "community_banners"])
);
create_dir_if_not_exists!(
&PathBufD::current().extend(&[config.dirs.media.as_str(), "uploads"])
);
write_if_track!(images_path->"default-avatar.svg"(DEFAULT_AVATAR) --config=config);
write_if_track!(images_path->"default-banner.svg"(DEFAULT_BANNER) --config=config);
@ -346,6 +350,10 @@ pub(crate) async fn initial_context(
if let Some(ua) = user {
ctx.insert("is_helper", &ua.permissions.check_helper());
ctx.insert("is_manager", &ua.permissions.check_manager());
ctx.insert(
"is_supporter",
&ua.permissions.check(FinePermission::SUPPORTER),
);
ctx.insert("home", ua.settings.default_timeline.relative_url());
} else {
ctx.insert("is_helper", &false);

View file

@ -31,6 +31,7 @@ version = "1.0.0"
"general:label.file_report" = "File report"
"general:label.account_banned" = "Account banned"
"general:label.account_banned_body" = "Your account has been banned for violating our policies."
"general:label.better_with_account" = "It's better with an account! Login or sign up to explore more."
"dialog:action.okay" = "Ok"
"dialog:action.continue" = "Continue"

View file

@ -559,6 +559,9 @@
MANAGE_PINS: 1 << 7,
MANAGE_COMMUNITY: 1 << 8,
MANAGE_QUESTIONS: 1 << 9,
MANAGE_CHANNELS: 1 << 10,
MANAGE_MESSAGES: 1 << 11,
MANAGE_EMOJIS: 1 << 12,
},
],
);

View file

@ -119,10 +119,17 @@
</nav>
{%- endmacro %} {% macro timelines_nav(selected="") -%}
<div class="pillmenu w-full">
{% if user %}
<a href="/" class="{% if selected == 'home' %}active{% endif %}">
{{ icon "newspaper" }}
<span>{{ text "general:link.home" }}</span>
</a>
{% else %}
<a href="/" class="{% if selected == 'all' %}active{% endif %}">
{{ icon "earth" }}
<span>{{ text "general:link.all" }}</span>
</a>
{% endif %}
<a href="/popular" class="{% if selected == 'popular' %}active{% endif %}">
{{ icon "trending-up" }}

View file

@ -1,26 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>{{ config.name }}</title>
{% endblock %} {% block body %}
<main class="flex justify-center" style="padding-top: 2rem">
<div class="flex flex-col gap-2" style="width: 20rem; max-width: 100%">
<h1 class="w-full text-center">{{ config.name }}</h1>
<div class="card flex flex-col gap-2">
<a href="/auth/login" class="button w-full">
{{ icon "log-in" }}
<span>{{ text "auth:action.login" }}</span>
</a>
<a href="/auth/register" class="button w-full secondary">
{{ icon "heart" }}
<span>{{ text "auth:action.register" }}</span>
</a>
</div>
<a href="/popular" class="button w-full quaternary">
{{ icon "trending-up" }}
<span>Explore popular feed</span>
</a>
</div>
</main>
{% endblock %}

View file

@ -232,6 +232,10 @@
SUPPORTER: 1 << 19,
MANAGE_REQUESTS: 1 << 20,
MANAGE_QUESTIONS: 1 << 21,
MANAGE_CHANNELS: 1 << 22,
MANAGE_MESSAGES: 1 << 23,
MANAGE_UPLOADS: 1 << 24,
MANAGE_EMOJIS: 1 << 25,
},
],
);

View file

@ -38,6 +38,34 @@
<div class="w-full flex flex-col gap-2" data-tab="account">
<div class="card tertiary flex flex-col gap-2" id="account_settings">
{% if config.stripe %}
<div class="card-nest" ui_ident="supporter_card">
<div class="card small flex items-center gap-2">
{{ icon "star" }}
<b>Supporter status</b>
</div>
<div class="card">
{% if is_supporter %}
<p>You <b>are</b> a supporter! Thank you for all that you do. You can manage your billing information below. <b>Please use your email address you supplied when paying to login to the billing portal.</b></p>
<a href="{{ config.stripe.billing_portal_url }}" class="button quaternary" target="_blank">Manage billing</a>
{% else %}
<p>You're <b>not</b> currently a supporter! No pressure, but it helps us do some pretty cool things! As a supporter, you'll get:</p>
<ul style="margin-bottom: 1rem">
<li>Vanity badge on profile</li>
<li>Ability to upload gif avatars/banners</li>
<li>Ability to use community emojis outside of their community <b>(soon)</b></li>
<li>Ability to upload and use gif emojis <b>(soon)</b></li>
</ul>
<a href="{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}" class="button" target="_blank">Become a supporter</a>
<span class="fade">Please use your <b>real email</b> when completing payment. It is required to manage your billing settings.</span>
{% endif %}
</div>
</div>
{% endif %}
<div class="card-nest" ui_ident="home_timeline">
<div class="card small">
<b>Home timeline</b>
@ -894,6 +922,7 @@
const theme_settings = document.getElementById("theme_settings");
ui.refresh_container(account_settings, [
"supporter_card",
"home_timeline",
"notifications",
"change_password",

View file

@ -4,6 +4,26 @@
<main class="flex flex-col gap-2">
{{ macros::timelines_nav(selected="all") }} {{
macros::timelines_secondary_nav(posts="/all", questions="/all/questions") }}
{% if not user %}
<div class="card-nest">
<div class="card small flex items-center gap-2">
{{ icon "heart" }}
<b>{{ text "general:label.better_with_account" }}</b>
</div>
<div class="card flex gap-2">
<a href="/auth/login" class="button">
{{ icon "log-in" }}
<span>{{ text "auth:action.login" }}</span>
</a>
<a href="/auth/register" class="button secondary">
{{ icon "user-plus" }}
<span>{{ text "auth:action.register" }}</span>
</a>
</div>
</div>
{% endif %}
<!-- prettier-ignore -->
<div class="card w-full flex flex-col gap-2">

View file

@ -757,7 +757,14 @@ media_theme_pref();
// link filter
self.define("link_filter", (_) => {
for (const anchor of Array.from(document.querySelectorAll("a"))) {
if (anchor.href.length === 0) {
if (
anchor.href.length === 0 ||
anchor.href.startsWith("https://github.com") ||
anchor.href.startsWith("https://trisua.com") ||
anchor.href.startsWith("https://buy.stripe.com") ||
anchor.href.startsWith("https://billing.stripe.com") ||
anchor.href.startsWith("https://last.fm")
) {
continue;
}

View file

@ -1,5 +1,6 @@
pub mod last_fm;
pub mod spotify;
pub mod stripe;
use std::collections::HashMap;

View file

@ -0,0 +1,148 @@
use std::time::Duration;
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json};
use tetratto_core::model::{auth::Notification, permissions::FinePermission, ApiReturn, Error};
use stripe::{EventObject, EventType};
use crate::State;
pub async fn stripe_webhook(
Extension(data): Extension<State>,
headers: HeaderMap,
body: String,
) -> impl IntoResponse {
let data = &(data.read().await).0;
if data.0.stripe.is_none() {
return Json(Error::MiscError("Disabled".to_string()).into());
}
let sig = match headers.get("Stripe-Signature") {
Some(s) => s,
None => return Json(Error::NotAllowed.into()),
};
let req = match stripe::Webhook::construct_event(
&body,
&sig.to_str().unwrap(),
&data.0.stripe.as_ref().unwrap().webhook_signing_secret,
) {
Ok(e) => e,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
match req.type_ {
EventType::CheckoutSessionCompleted => {
// checkout session ended, store user customer id (from stripe) in their user data
let session = match req.data.object {
EventObject::CheckoutSession(c) => c,
_ => unreachable!("cannot be this"),
};
let user_id = session
.client_reference_id
.unwrap()
.parse::<usize>()
.unwrap();
let customer_id = session.customer.unwrap().id();
let user = match data.get_user_by_id(user_id).await {
Ok(ua) => ua,
Err(e) => return Json(e.into()),
};
tracing::info!("subscribe {} (stripe: {})", user.id, customer_id);
if let Err(e) = data
.update_user_stripe_id(user.id, customer_id.as_str())
.await
{
return Json(e.into());
}
}
EventType::InvoicePaymentSucceeded => {
// payment finished and subscription created
// we're doing this *instead* of CustomerSubscriptionDeleted because
// the invoice happens *after* the checkout session ends... which is what we need
let invoice = match req.data.object {
EventObject::Invoice(c) => c,
_ => unreachable!("cannot be this"),
};
let customer_id = invoice.customer.unwrap().id();
// allow 10s for everything to finalize
tokio::time::sleep(Duration::from_secs(10)).await;
// pull user and update role
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
Ok(ua) => ua,
Err(e) => return Json(e.into()),
};
tracing::info!("invoice {} (stripe: {})", user.id, customer_id);
let new_user_permissions = user.permissions | FinePermission::SUPPORTER;
if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
if let Err(e) = data
.create_notification(Notification::new(
"Welcome new supporter!".to_string(),
"Thank you for your support! Your account has been updated with your new role."
.to_string(),
user.id,
))
.await
{
return Json(e.into());
}
}
EventType::CustomerSubscriptionDeleted => {
// payment failed and subscription deleted
let subscription = match req.data.object {
EventObject::Subscription(c) => c,
_ => unreachable!("cannot be this"),
};
let customer_id = subscription.customer.id();
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
Ok(ua) => ua,
Err(e) => return Json(e.into()),
};
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id);
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
if let Err(e) = data
.update_user_role(user.id, new_user_permissions, user.clone(), true)
.await
{
return Json(e.into());
}
if let Err(e) = data
.create_notification(Notification::new(
"Sorry to see you go... :(".to_string(),
"Thank you for your past support! Please feel free to leave us feedback on why you decided to cancel."
.to_string(),
user.id,
))
.await
{
return Json(e.into());
}
}
_ => return Json(Error::Unknown.into()),
}
Json(ApiReturn {
ok: true,
message: "Acceptable".to_string(),
payload: (),
})
}

View file

@ -247,7 +247,7 @@ pub async fn update_user_role_request(
None => return Json(Error::NotAllowed.into()),
};
match data.update_user_role(id, req.role, user).await {
match data.update_user_role(id, req.role, user, false).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "User updated".to_string(),

View file

@ -281,6 +281,10 @@ pub fn routes() -> Router {
"/auth/user/connections/last_fm/api_proxy",
post(auth::connections::last_fm::proxy_request),
)
.route(
"/service_hooks/stripe",
post(auth::connections::stripe::stripe_webhook),
)
// channels
.route("/channels", post(channels::channels::create_request))
.route(

View file

@ -35,9 +35,22 @@ pub async fn index_request(
Some(ua) => ua,
None => {
return {
// all timeline for unauthenticated users
// i'm only changing this for stripe
let list = match data.0.get_latest_posts(12, req.page).await {
Ok(l) => match data.0.fill_posts_with_community(l, 0, &Vec::new()).await {
Ok(l) => l,
Err(e) => return Html(render_error(e, &jar, &data, &None).await),
},
Err(e) => return Html(render_error(e, &jar, &data, &None).await),
};
let lang = get_lang!(jar, data.0);
let context = initial_context(&data.0.0, lang, &None).await;
Html(data.1.render("misc/index.html", &context).unwrap())
let mut context = initial_context(&data.0.0, lang, &None).await;
context.insert("list", &list);
context.insert("page", &req.page);
Html(data.1.render("timelines/all.html", &context).unwrap())
};
}
};

View file

@ -163,6 +163,39 @@ pub struct ConnectionsConfig {
pub last_fm_secret: Option<String>,
}
/// Configuration for Stripe integration.
///
/// User IDs are sent to Stripe through the payment link.
/// <https://docs.stripe.com/payment-links/url-parameters#streamline-reconciliation-with-a-url-parameter>
///
/// # Testing
///
/// - Run `stripe login` using the Stripe CLI
/// - Run `stripe listen --forward-to localhost:4118/api/v1/service_hooks/stripe`
/// - Use testing card numbers: <https://docs.stripe.com/testing?testing-method=card-numbers#visa>
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
pub struct StripeConfig {
/// Payment link from the Stripe dashboard.
///
/// 1. Create a product and set the price for your membership
/// 2. Set the product price to a recurring subscription
/// 3. Create a payment link for the new product
/// 4. The payment link pasted into this config field should NOT include a query string
pub payment_link: String,
/// To apply benefits to user accounts, you should then go into the Stripe developer
/// "workbench" and create a new webhook. The webhook needs the scopes:
/// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`.
///
/// The webhook's destination address should be `{your server origin}/api/v1/service_hooks/stripe`.
///
/// The signing secret can be found on the right after you have created the webhook.
pub webhook_signing_secret: String,
/// The URL of your customer billing portal.
///
/// <https://docs.stripe.com/no-code/customer-portal>
pub billing_portal_url: String,
}
/// Configuration file
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Config {
@ -224,6 +257,8 @@ pub struct Config {
/// into every HTML template. They support access to template fields like `{{ user }}`.
#[serde(default)]
pub html_footer_path: String,
#[serde(default)]
pub stripe: Option<StripeConfig>,
}
fn default_name() -> String {
@ -311,6 +346,7 @@ impl Default for Config {
town_square: 0,
connections: default_connections(),
html_footer_path: String::new(),
stripe: None,
}
}
}

View file

@ -45,6 +45,7 @@ impl DataManager {
post_count: get!(x->15(i32)) as usize,
request_count: get!(x->16(i32)) as usize,
connections: serde_json::from_str(&get!(x->17(String)).to_string()).unwrap(),
stripe_id: get!(x->18(String)),
}
}
@ -139,7 +140,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)",
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)",
params![
&(data.id as i64),
&(data.created as i64),
@ -159,6 +160,7 @@ impl DataManager {
&0_i32,
&0_i32,
&serde_json::to_string(&data.connections).unwrap(),
&""
]
);
@ -433,24 +435,27 @@ impl DataManager {
id: usize,
role: FinePermission,
user: User,
force: bool,
) -> Result<()> {
// check permission
if !user.permissions.check(FinePermission::MANAGE_USERS) {
return Err(Error::NotAllowed);
}
let other_user = self.get_user_by_id(id).await?;
if other_user.permissions.check_manager() && !user.permissions.check_admin() {
return Err(Error::MiscError(
"Cannot manage the role of other managers".to_string(),
));
}
if !force {
// check permission
if !user.permissions.check(FinePermission::MANAGE_USERS) {
return Err(Error::NotAllowed);
}
if other_user.permissions == user.permissions {
return Err(Error::MiscError(
"Cannot manage users of equal level to you".to_string(),
));
if other_user.permissions.check_manager() && !user.permissions.check_admin() {
return Err(Error::MiscError(
"Cannot manage the role of other managers".to_string(),
));
}
if other_user.permissions == user.permissions {
return Err(Error::MiscError(
"Cannot manage users of equal level to you".to_string(),
));
}
}
// ...
@ -645,6 +650,9 @@ impl DataManager {
auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_subscriptions(HashMap<usize, usize>)@get_user_by_id -> "UPDATE users SET subscriptions = $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);
auto_method!(incr_user_notifications()@get_user_by_id -> "UPDATE users SET notification_count = notification_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr);
auto_method!(decr_user_notifications()@get_user_by_id -> "UPDATE users SET notification_count = notification_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr);

View file

@ -30,6 +30,8 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_IPBLOCKS).unwrap();
execute!(&conn, common::CREATE_TABLE_CHANNELS).unwrap();
execute!(&conn, common::CREATE_TABLE_MESSAGES).unwrap();
execute!(&conn, common::CREATE_TABLE_UPLOADS).unwrap();
execute!(&conn, common::CREATE_TABLE_EMOJIS).unwrap();
self.2
.set("atto.active_connections:users".to_string(), "0".to_string())

View file

@ -15,3 +15,5 @@ pub const CREATE_TABLE_QUESTIONS: &str = include_str!("./sql/create_questions.sq
pub const CREATE_TABLE_IPBLOCKS: &str = include_str!("./sql/create_ipblocks.sql");
pub const CREATE_TABLE_CHANNELS: &str = include_str!("./sql/create_channels.sql");
pub const CREATE_TABLE_MESSAGES: &str = include_str!("./sql/create_messages.sql");
pub const CREATE_TABLE_UPLOADS: &str = include_str!("./sql/create_uploads.sql");
pub const CREATE_TABLE_EMOJIS: &str = include_str!("./sql/create_emojis.sql");

View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS emojis (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
community BIGINT NOT NULL,
upload_id BIGINT NOT NULL,
name TEXT NOT NULL
)

View file

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS uploads (
id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL,
owner BIGINT NOT NULL,
what TEXT NOT NULL
)

View file

@ -0,0 +1,191 @@
use super::*;
use crate::cache::Cache;
use crate::model::{
Error, Result, auth::User, permissions::FinePermission,
communities_permissions::CommunityPermission, uploads::CustomEmoji,
};
use crate::{auto_method, execute, get, query_row, query_rows, params};
#[cfg(feature = "sqlite")]
use rusqlite::Row;
#[cfg(feature = "postgres")]
use tokio_postgres::Row;
impl DataManager {
/// Get a [`CustomEmoji`] from an SQL row.
pub(crate) fn get_emoji_from_row(
#[cfg(feature = "sqlite")] x: &Row<'_>,
#[cfg(feature = "postgres")] x: &Row,
) -> CustomEmoji {
CustomEmoji {
id: get!(x->0(i64)) as usize,
owner: get!(x->1(i64)) as usize,
created: get!(x->2(i64)) as usize,
community: get!(x->3(i64)) as usize,
upload_id: get!(x->4(i64)) as usize,
name: get!(x->5(String)),
}
}
auto_method!(get_emoji_by_id(usize as i64)@get_emoji_from_row -> "SELECT * FROM emojis WHERE id = $1" --name="emoji" --returns=CustomEmoji --cache-key-tmpl="atto.emoji:{}");
/// Get all emojis by community.
///
/// # Arguments
/// * `community` - the ID of the community to fetch emojis for
pub async fn get_emojis_by_community(&self, community: usize) -> Result<Vec<CustomEmoji>> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM emojis WHERE community = $1 ORDER BY name ASC",
&[&(community as i64)],
|x| { Self::get_emoji_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("emoji".to_string()));
}
Ok(res.unwrap())
}
/// Get all emojis by user.
///
/// # Arguments
/// * `user` - the ID of the user to fetch emojis for
pub async fn get_emojis_by_user(&self, user: usize) -> Result<Vec<CustomEmoji>> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM emojis WHERE (owner = $1 OR members LIKE $2) AND community = 0 ORDER BY created DESC",
params![&(user as i64), &format!("%{user}%")],
|x| { Self::get_emoji_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("emoji".to_string()));
}
Ok(res.unwrap())
}
/// Get a emoji given its `owner` and a member.
///
/// # Arguments
/// * `owner` - the ID of the owner
/// * `member` - the ID of the member
pub async fn get_emoji_by_owner_member(
&self,
owner: usize,
member: usize,
) -> Result<CustomEmoji> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_row!(
&conn,
"SELECT * FROM emojis WHERE owner = $1 AND members = $2 AND community = 0 ORDER BY created DESC",
params![&(owner as i64), &format!("[{member}]")],
|x| { Ok(Self::get_emoji_from_row(x)) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("emoji".to_string()));
}
Ok(res.unwrap())
}
/// Create a new emoji in the database.
///
/// # Arguments
/// * `data` - a mock [`CustomEmoji`] object to insert
pub async fn create_emoji(&self, data: CustomEmoji) -> Result<()> {
let user = self.get_user_by_id(data.owner).await?;
// check user permission in community
if data.community != 0 {
let membership = self
.get_membership_by_owner_community(user.id, data.community)
.await?;
if !membership.role.check(CommunityPermission::MANAGE_EMOJIS)
&& !user.permissions.check(FinePermission::MANAGE_EMOJIS)
{
return Err(Error::NotAllowed);
}
}
// ...
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"INSERT INTO emojis VALUES ($1, $2, $3, $4, $5, $6)",
params![
&(data.id as i64),
&(data.owner as i64),
&(data.created as i64),
&(data.community as i64),
&(data.upload_id as i64),
&data.name
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(())
}
pub async fn delete_emoji(&self, id: usize, user: &User) -> Result<()> {
let emoji = self.get_emoji_by_id(id).await?;
// check user permission in community
if user.id != emoji.owner {
let membership = self
.get_membership_by_owner_community(user.id, emoji.community)
.await?;
if !membership.role.check(CommunityPermission::MANAGE_EMOJIS) {
return Err(Error::NotAllowed);
}
}
// ...
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(&conn, "DELETE FROM emojis WHERE id = $1", &[&(id as i64)]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// delete uploads
self.delete_upload(emoji.upload_id).await?;
// ...
self.2.remove(format!("atto.emoji:{}", id)).await;
Ok(())
}
auto_method!(update_emoji_name(&str)@get_emoji_by_id:MANAGE_EMOJIS -> "UPDATE emojis SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.emoji:{}");
}

View file

@ -4,6 +4,7 @@ mod common;
mod communities;
pub mod connections;
mod drivers;
mod emojis;
mod ipbans;
mod ipblocks;
mod memberships;
@ -13,6 +14,7 @@ mod questions;
mod reactions;
mod reports;
mod requests;
mod uploads;
mod user_warnings;
mod userblocks;
mod userfollows;

View file

@ -0,0 +1,122 @@
use std::fs::{exists, remove_file};
use super::*;
use crate::cache::Cache;
use crate::model::{Error, Result, uploads::MediaUpload};
use crate::{auto_method, execute, get, query_row, query_rows, params};
use pathbufd::PathBufD;
#[cfg(feature = "sqlite")]
use rusqlite::Row;
#[cfg(feature = "postgres")]
use tokio_postgres::Row;
impl DataManager {
/// Get a [`MediaUpload`] from an SQL row.
pub(crate) fn get_upload_from_row(
#[cfg(feature = "sqlite")] x: &Row<'_>,
#[cfg(feature = "postgres")] x: &Row,
) -> MediaUpload {
MediaUpload {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize,
what: serde_json::from_str(&get!(x->3(String))).unwrap(),
}
}
auto_method!(get_upload_by_id(usize as i64)@get_upload_from_row -> "SELECT * FROM uploads WHERE id = $1" --name="upload" --returns=MediaUpload --cache-key-tmpl="atto.uploads:{}");
/// Get all uploads (paginated).
///
/// # Arguments
/// * `batch` - the limit of items in each page
/// * `page` - the page number
pub async fn get_uploads(&self, batch: usize, page: usize) -> Result<Vec<MediaUpload>> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = query_rows!(
&conn,
"SELECT * FROM uploads ORDER BY created DESC LIMIT $1 OFFSET $2",
&[&(batch as i64), &((page * batch) as i64)],
|x| { Self::get_upload_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("upload".to_string()));
}
Ok(res.unwrap())
}
/// Create a new upload in the database.
///
/// # Arguments
/// * `data` - a mock [`MediaUpload`] object to insert
pub async fn create_upload(&self, data: MediaUpload) -> Result<()> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"INSERT INTO uploads VALUES ($1, $2, $3, $4)",
params![
&(data.id as i64),
&(data.created as i64),
&(data.owner as i64),
&serde_json::to_string(&data.what).unwrap().as_str(),
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// return
Ok(())
}
pub async fn delete_upload(&self, id: usize) -> Result<()> {
// if !user.permissions.check(FinePermission::MANAGE_UPLOADS) {
// return Err(Error::NotAllowed);
// }
// delete file
// it's most important that the file gets off the file system first, even
// if there's an issue in the database
//
// the actual file takes up much more space than the database entry.
let path = PathBufD::current().extend(&[self.0.dirs.media.as_str(), "uploads"]);
if let Ok(exists) = exists(&path) {
if exists {
if let Err(e) = remove_file(&path) {
return Err(Error::MiscError(e.to_string()));
}
}
} else {
return Err(Error::GeneralNotFound("file".to_string()));
}
// delete from database
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(&conn, "DELETE FROM uploads WHERE id = $1", &[&(id as i64)]);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
self.2.remove(format!("atto.upload:{}", id)).await;
// return
Ok(())
}
}

View file

@ -40,6 +40,9 @@ pub struct User {
/// External service connection details.
#[serde(default)]
pub connections: UserConnections,
/// The user's Stripe customer ID.
#[serde(default)]
pub stripe_id: String,
}
pub type UserConnections =
@ -245,6 +248,7 @@ impl User {
post_count: 0,
request_count: 0,
connections: HashMap::new(),
stripe_id: String::new(),
}
}

View file

@ -20,6 +20,7 @@ bitflags! {
const MANAGE_QUESTIONS = 1 << 9;
const MANAGE_CHANNELS = 1 << 10;
const MANAGE_MESSAGES = 1 << 11;
const MANAGE_EMOJIS = 1 << 12;
const _ = !0;
}

View file

@ -6,6 +6,7 @@ pub mod oauth;
pub mod permissions;
pub mod reactions;
pub mod requests;
pub mod uploads;
#[cfg(feature = "redis")]
pub mod channels;

View file

@ -32,6 +32,8 @@ bitflags! {
const MANAGE_QUESTIONS = 1 << 21;
const MANAGE_CHANNELS = 1 << 22;
const MANAGE_MESSAGES = 1 << 23;
const MANAGE_UPLOADS = 1 << 24;
const MANAGE_EMOJIS = 1 << 25;
const _ = !0;
}

View file

@ -0,0 +1,66 @@
use serde::{Serialize, Deserialize};
use tetratto_shared::{snow::AlmostSnowflake, unix_epoch_timestamp};
#[derive(Serialize, Deserialize)]
pub enum MediaType {
#[serde(alias = "image/webp")]
Webp,
#[serde(alias = "image/avif")]
Avif,
#[serde(alias = "image/png")]
Png,
#[serde(alias = "image/jpg")]
Jpg,
#[serde(alias = "image/gif")]
Gif,
}
#[derive(Serialize, Deserialize)]
pub struct MediaUpload {
pub id: usize,
pub created: usize,
pub owner: usize,
pub what: MediaType,
}
impl MediaUpload {
/// Create a new [`MediaUpload`].
pub fn new(what: MediaType, owner: usize) -> Self {
Self {
id: AlmostSnowflake::new(1234567890)
.to_string()
.parse::<usize>()
.unwrap(),
created: unix_epoch_timestamp() as usize,
owner,
what,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct CustomEmoji {
pub id: usize,
pub created: usize,
pub owner: usize,
pub community: usize,
pub upload_id: usize,
pub name: String,
}
impl CustomEmoji {
/// Create a new [`CustomEmoji`].
pub fn new(owner: usize, community: usize, upload_id: usize, name: String) -> Self {
Self {
id: AlmostSnowflake::new(1234567890)
.to_string()
.parse::<usize>()
.unwrap(),
created: unix_epoch_timestamp() as usize,
owner,
community,
upload_id,
name,
}
}
}