add: custom emojis

fix: don't show reposts of posts from blocked users
fix: don't show questions when they're from users you've blocked
This commit is contained in:
trisua 2025-05-10 21:58:02 -04:00
parent 9f187039e6
commit 275dd0a1eb
25 changed files with 697 additions and 61 deletions

8
Cargo.lock generated
View file

@ -3599,7 +3599,7 @@ dependencies = [
[[package]]
name = "tetratto"
version = "2.3.0"
version = "3.0.0"
dependencies = [
"ammonia",
"async-stripe",
@ -3629,7 +3629,7 @@ dependencies = [
[[package]]
name = "tetratto-core"
version = "2.3.0"
version = "3.0.0"
dependencies = [
"async-recursion",
"base16ct",
@ -3653,7 +3653,7 @@ dependencies = [
[[package]]
name = "tetratto-l10n"
version = "2.3.0"
version = "3.0.0"
dependencies = [
"pathbufd",
"serde",
@ -3662,7 +3662,7 @@ dependencies = [
[[package]]
name = "tetratto-shared"
version = "2.3.0"
version = "3.0.0"
dependencies = [
"ammonia",
"chrono",

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto"
version = "2.3.0"
version = "3.0.0"
edition = "2024"
[features]

View file

@ -112,6 +112,9 @@ version = "1.0.0"
"communities:action.create_channel" = "Create channel"
"communities:label.chats" = "Chats"
"communities:label.show_community" = "Show community"
"communities:tab.emojis" = "Emojis"
"communities:label.upload" = "Upload"
"communities:label.file" = "File"
"notifs:action.mark_as_read" = "Mark as read"
"notifs:action.mark_as_unread" = "Mark as unread"

View file

@ -311,6 +311,12 @@ img.contain {
object-fit: contain;
}
img.emoji {
width: 1em;
height: 1em;
aspect-ratio: 1 / 1;
}
/* avatar/banner */
.avatar {
--size: 50px;

View file

@ -327,7 +327,7 @@ hide_user_menu=true) }}
}
.message.grouped {
padding: 0.25rem 1rem 0.25rem calc(1rem + 0.5rem + 39px);
padding: 0.25rem 1rem 0.25rem calc(1rem + 0.5rem + 31px);
}
body:not(.sidebars_shown) .sidebar {

View file

@ -23,6 +23,11 @@
{{ icon "rss" }}
<span>{{ text "communities:tab.channels" }}</span>
</a>
{% endif %} {% if can_manage_emojis %}
<a href="#/emojis" data-tab-button="emojis">
{{ icon "smile" }}
<span>{{ text "communities:tab.emojis" }}</span>
</a>
{% endif %}
</div>
@ -436,6 +441,167 @@
});
}
</script>
{% endif %} {% if can_manage_emojis %}
<div
class="card tertiary w-full hidden flex flex-col gap-2"
data-tab="emojis"
>
{{ components::supporter_ad(body="Become a supporter to upload GIF
animated emojis!") }}
<div class="card-nest" ui_ident="change_banner">
<div class="card small flex items-center gap-2">
{{ icon "upload" }}
<b>{{ text "communities:label.upload" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="upload_emoji(event)"
>
<div class="flex flex-col gap-1">
<label for="name"
>{{ text "communities:label.name" }}</label
>
<input
type="text"
name="name"
id="name"
placeholder="name"
required
minlength="2"
maxlength="32"
/>
</div>
<div class="flex flex-col gap-1">
<label for="file"
>{{ text "communities:label.file" }}</label
>
<input
id="banner_file"
name="file"
type="file"
accept="image/png,image/jpeg,image/avif,image/webp"
class="w-full"
/>
</div>
<button>{{ text "communities:action.create" }}</button>
<span class="fade"
>Emojis can be a maximum of 256 KiB, or 512x512px (width x
height).</span
>
</form>
</div>
{% for emoji in emojis %}
<div
class="card secondary flex flex-wrap gap-2 items-center justify-between"
>
<div class="flex gap-2 items-center">
<img
src="/api/v1/communities/{{ community.id }}/emojis/{{ emoji.name }}"
alt="{{ emoji.name }}"
class="emoji"
loading="lazy"
/>
<b>{{ emoji.name }}</b>
</div>
<div class="flex gap-2">
<button
class="quaternary small"
onclick="rename_emoji('{{ emoji.id }}')"
>
{{ icon "pencil" }}
<span>{{ text "chats:action.rename" }}</span>
</button>
<button
class="quaternary small red"
onclick="remove_emoji('{{ emoji.id }}')"
>
{{ icon "x" }}
<span>{{ text "stacks:label.remove" }}</span>
</button>
</div>
</div>
{% endfor %}
</div>
<script>
globalThis.upload_emoji = (e) => {
e.preventDefault();
e.target.querySelector("button").style.display = "none";
fetch(
`/api/v1/communities/{{ community.id }}/emojis/${e.target.name.value}`,
{
method: "POST",
body: e.target.file.files[0],
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
e.target.querySelector("button").removeAttribute("style");
});
alert("Emoji upload in progress. Please wait!");
};
globalThis.rename_emoji = async (id) => {
const name = await trigger("atto::prompt", ["New emoji name:"]);
if (!name) {
return;
}
fetch(`/api/v1/emojis_id/${id}/name`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.remove_emoji = async (id) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this? This action is permanent.",
]))
) {
return;
}
fetch(`/api/v1/emojis_id/${id}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
</script>
{% endif %}
</main>

View file

@ -179,6 +179,9 @@ and show_community and community.id != config.town_square or question %}
<div
class="card flex flex-col gap-2 {% if secondary %}secondary{% endif %}"
id="post:{{ post.id }}"
data-community="{{ post.community }}"
data-ownsup="{{ owner.permissions|has_supporter }}"
hook="verify_emojis"
>
<div class="w-full flex gap-2">
<a href="/@{{ owner.username }}">
@ -1202,6 +1205,17 @@ show_kick=false, secondary=false) -%}
></emoji-picker>
<script>
setTimeout(async () => {
document.querySelector("emoji-picker").customEmoji =
await trigger("me::emojis");
const style = document.createElement("style");
style.textContent = `.custom-emoji { border-radius: 4px !important; } .category { font-weight: 600; }`;
document
.querySelector("emoji-picker")
.shadowRoot.appendChild(style);
}, 150);
document
.querySelector("emoji-picker")
.addEventListener("emoji-click", async (event) => {
@ -1214,14 +1228,20 @@ show_kick=false, secondary=false) -%}
return;
}
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).value += ` :${await (
await fetch("/api/v1/lookup_emoji", {
method: "POST",
body: event.detail.unicode,
})
).text()}:`;
if (event.detail.unicode) {
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).value += ` :${await (
await fetch("/api/v1/lookup_emoji", {
method: "POST",
body: event.detail.unicode,
})
).text()}:`;
} else {
document.getElementById(
window.EMOJI_PICKER_TEXT_ID,
).value += ` :${event.detail.emoji.shortcodes[0]}:`;
}
document.getElementById("emoji_dialog").close();
});

View file

@ -71,6 +71,10 @@
class="card flex flex-col items-center gap-2"
id="social"
>
{% if profile.settings.status %}
<p>{{ profile.settings.status }}</p>
{% endif %}
<div class="w-full flex">
<a
href="/@{{ profile.username }}/followers"

View file

@ -112,6 +112,7 @@ macros -%}
atto["hooks::check_reactions"]();
atto["hooks::tabs"]();
atto["hooks::spotify_time_text"](); // spotify durations
atto["hooks::verify_emoji"]();
if (document.getElementById("tokens")) {
trigger("me::render_token_picker", [

View file

@ -310,7 +310,7 @@
return;
}
fetch(`/api/v1/stacks/{{ stack.id }}`, {
fetch("/api/v1/stacks/{{ stack.id }}", {
method: "DELETE",
})
.then((res) => res.json())

View file

@ -215,6 +215,28 @@ media_theme_pref();
});
// hooks
self.define("hooks::verify_emoji", (_) => {
for (const post of document.querySelectorAll("[hook=verify_emojis]")) {
const community_id = post.getAttribute("data-community");
const owner_is_supporter =
post.getAttribute("data-ownsup") === "true";
if (owner_is_supporter) {
return;
}
// not supporter; check emojis
for (const img of post.querySelectorAll(".emoji")) {
if (
img.src.split("/api/v1/communities/")[1].split("/")[0] !==
community_id
) {
img.remove();
}
}
}
});
self.define("hooks::scroll", (_, scroll_element, track_element) => {
const goals = [150, 250, 500, 1000];

View file

@ -24,6 +24,9 @@
res.message,
]);
delete self.LOGIN_ACCOUNT_TOKENS[res.payload];
self.set_login_account_tokens(self.LOGIN_ACCOUNT_TOKENS);
if (res.ok) {
setTimeout(() => {
window.location.href = "/";
@ -345,6 +348,26 @@
}
});
self.define("emojis", async () => {
const payload = (await (await fetch("/api/v1/my_emojis")).json())
.payload;
const out = [];
for (const [community, [category, emojis]] of Object.entries(payload)) {
for (const emoji of emojis) {
out.push({
category,
name: emoji.name,
shortcodes: [`${community}.${emoji.name}`],
url: `/api/v1/communities/${community}/emojis/${emoji.name}`,
});
}
}
return out;
});
// token switcher
self.define(
"set_login_account_tokens",

View file

@ -1,7 +1,12 @@
use std::time::Duration;
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json};
use tetratto_core::model::{auth::Notification, permissions::FinePermission, ApiReturn, Error};
use tetratto_core::model::{
auth::{User, Notification},
moderation::AuditLogEntry,
permissions::FinePermission,
ApiReturn, Error,
};
use stripe::{EventObject, EventType};
use crate::State;
@ -70,14 +75,50 @@ pub async fn stripe_webhook(
let customer_id = invoice.customer.unwrap().id();
// allow 30s for everything to finalize
tokio::time::sleep(Duration::from_secs(30)).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()),
};
let mut retries: usize = 0;
let mut user: Option<User> = None;
loop {
if retries >= 5 {
// we've already tried 5 times (10 seconds of waiting)... it's not
// going to happen
//
// we're going to report this error to the audit log so someone can
// check manually later
if let Err(e) = data
.create_audit_log_entry(AuditLogEntry::new(
0,
format!("invoice tier update failed: stripe {customer_id}"),
))
.await
{
return Json(e.into());
}
return Json(Error::GeneralNotFound("user".to_string()).into());
}
match data.get_user_by_stripe_id(customer_id.as_str()).await {
Ok(ua) => {
if !user.is_none() {
break;
}
user = Some(ua);
break;
}
Err(_) => {
tracing::info!("checkout session not stored in db yet");
retries += 1;
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
}
}
let user = user.unwrap();
tracing::info!("found subscription user in {retries} tries");
if user.permissions.check(FinePermission::SUPPORTER) {
return Json(ApiReturn {

View file

@ -216,7 +216,7 @@ pub async fn logout_request(
Json(ApiReturn {
ok: true,
message: "Goodbye!".to_string(),
payload: (),
payload: Some(user.username.clone()),
}),
)
}

View file

@ -1,4 +1,19 @@
use axum::response::IntoResponse;
use std::fs::exists;
use image::ImageFormat;
use pathbufd::PathBufD;
use crate::{
get_user_from_token,
image::{save_buffer, Image},
routes::api::v1::{auth::images::read_image, UpdateEmojiName},
State,
};
use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{
uploads::{CustomEmoji, MediaType, MediaUpload},
ApiReturn, Error,
};
/// Expand a unicode emoji into its Gemoji shortcode.
pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse {
@ -10,3 +25,209 @@ pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse {
None => String::new(),
}
}
pub async fn get_request(
Path((community, name)): Path<(usize, String)>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
if community == 0 {
return Err((
[("Content-Type", "image/svg+xml")],
Body::from(read_image(PathBufD::current().extend(&[
data.0.dirs.media.as_str(),
"images",
"default-avatar.svg",
]))),
));
}
let emoji = match data.get_emoji_by_community_name(community, &name).await {
Ok(ua) => ua,
Err(_) => {
return Err((
[("Content-Type", "image/svg+xml")],
Body::from(read_image(PathBufD::current().extend(&[
data.0.dirs.media.as_str(),
"images",
"default-avatar.svg",
]))),
));
}
};
let upload = data
.get_upload_by_id(emoji.0.unwrap().upload_id)
.await
.unwrap();
let path = upload.path(&data.0);
if !exists(&path).unwrap() {
return Err((
[("Content-Type", "image/svg+xml")],
Body::from(read_image(PathBufD::current().extend(&[
data.0.dirs.media.as_str(),
"images",
"default-avatar.svg",
]))),
));
}
Ok((
[("Content-Type", upload.what.mime())],
Body::from(read_image(path)),
))
}
// maximum file dimensions: 512x512px (256KiB)
pub const MAXIUMUM_FILE_SIZE: usize = 262144;
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path((community, name)): Path<(usize, String)>,
img: Image,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
// check file size
if img.0.len() > MAXIUMUM_FILE_SIZE {
return Json(Error::DataTooLong("image".to_string()).into());
}
// make sure emoji doesn't already exist in community
if data
.get_emoji_by_community_name(community, &name)
.await
.is_ok()
{
return Json(
Error::MiscError("This emoji name is already in use in this community".to_string())
.into(),
);
}
// create upload
let upload = match data
.create_upload(MediaUpload::new(
if img.1 == "image/gif" {
MediaType::Gif
} else {
MediaType::Webp
},
user.id,
))
.await
{
Ok(u) => u,
Err(e) => return Json(e.into()),
};
// create emoji
let is_animated = img.1 == "image/gif";
match data
.create_emoji(CustomEmoji::new(
user.id,
community,
upload.id,
name,
is_animated,
))
.await
{
Ok(_) => {
if let Err(e) = save_buffer(
&upload.path(&data.0).to_string(),
img.0.to_vec(),
if is_animated {
ImageFormat::Gif
} else {
ImageFormat::WebP
},
) {
return Json(Error::MiscError(e.to_string()).into());
}
Json(ApiReturn {
ok: true,
message: "Emoji created".to_string(),
payload: (),
})
}
Err(e) => {
if let Err(e) = upload.remove(&data.0) {
return Json(e.into());
}
Json(e.into())
}
}
}
pub async fn update_name_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<UpdateEmojiName>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.update_emoji_name(id, user, &req.name).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Emoji updated".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) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.delete_emoji(id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Emoji deleted".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn get_my_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data.get_user_emojis(user.id).await {
Ok(d) => Json(ApiReturn {
ok: true,
message: d.len().to_string(),
payload: Some(d),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -322,6 +322,23 @@ pub fn routes() -> Router {
"/lookup_emoji",
post(communities::emojis::get_emoji_shortcode),
)
.route("/my_emojis", get(communities::emojis::get_my_request))
.route(
"/communities/{id}/emojis/{name}",
get(communities::emojis::get_request),
)
.route(
"/communities/{id}/emojis/{name}",
post(communities::emojis::create_request),
)
.route(
"/emojis_id/{id}/name",
post(communities::emojis::update_name_request),
)
.route(
"/emojis_id/{id}",
delete(communities::emojis::delete_request),
)
// stacks
.route("/stacks", post(stacks::create_request))
.route("/stacks/{id}/name", post(stacks::update_name_request))
@ -547,3 +564,8 @@ pub struct UpdateStackSort {
pub struct AddOrRemoveStackUser {
pub username: String,
}
#[derive(Deserialize)]
pub struct UpdateEmojiName {
pub name: String,
}

View file

@ -533,6 +533,14 @@ pub async fn settings_request(
let can_manage_channels = membership.role.check(CommunityPermission::MANAGE_CHANNELS)
| user.permissions.check(FinePermission::MANAGE_CHANNELS);
let emojis = match data.0.get_emojis_by_community(community.id).await {
Ok(p) => p,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let can_manage_emojis = membership.role.check(CommunityPermission::MANAGE_EMOJIS)
| user.permissions.check(FinePermission::MANAGE_EMOJIS);
// init context
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0, lang, &Some(user)).await;
@ -546,6 +554,9 @@ pub async fn settings_request(
context.insert("can_manage_channels", &can_manage_channels);
context.insert("channels", &channels);
context.insert("can_manage_emojis", &can_manage_emojis);
context.insert("emojis", &emojis);
// return
Ok(Html(
data.1
@ -574,11 +585,17 @@ pub async fn post_request(
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
let ignore_users = if let Some(ref ua) = user {
data.0.get_userblocks_receivers(ua.id).await
} else {
Vec::new()
};
// check repost
let reposting = data.0.get_post_reposting(&post).await;
let reposting = data.0.get_post_reposting(&post, &ignore_users).await;
// check question
let question = match data.0.get_post_question(&post).await {
let question = match data.0.get_post_question(&post, &ignore_users).await {
Ok(q) => q,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
@ -681,11 +698,17 @@ pub async fn reposts_request(
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
let ignore_users = if let Some(ref ua) = user {
data.0.get_userblocks_receivers(ua.id).await
} else {
Vec::new()
};
// check repost
let reposting = data.0.get_post_reposting(&post).await;
let reposting = data.0.get_post_reposting(&post, &ignore_users).await;
// check question
let question = match data.0.get_post_question(&post).await {
let question = match data.0.get_post_question(&post, &ignore_users).await {
Ok(q) => q,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-core"
version = "2.3.0"
version = "3.0.0"
edition = "2024"
[features]

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
use super::*;
use crate::cache::Cache;
use crate::model::{
@ -55,6 +57,29 @@ impl DataManager {
Ok(res.unwrap())
}
/// Get all emojis by their community for the communities the given user is in.
pub async fn get_user_emojis(
&self,
id: usize,
) -> Result<HashMap<usize, (String, Vec<CustomEmoji>)>> {
let memberships = self.get_memberships_by_owner(id).await?;
let mut out = HashMap::new();
for membership in memberships {
let community = self.get_community_by_id(membership.community).await?;
out.insert(
community.id,
(
community.title.clone(),
self.get_emojis_by_community(community.id).await?,
),
);
}
Ok(out)
}
/// Get an emoji by community and name.
///
/// # Arguments
@ -134,8 +159,8 @@ impl DataManager {
"INSERT INTO emojis VALUES ($1, $2, $3, $4, $5, $6, $7)",
params![
&(data.id as i64),
&(data.owner as i64),
&(data.created as i64),
&(data.owner as i64),
&(data.community as i64),
&(data.upload_id as i64),
&data.name,
@ -176,7 +201,7 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
// delete uploads
// delete upload
self.delete_upload(emoji.upload_id).await?;
// ...
@ -184,5 +209,5 @@ impl DataManager {
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:{}");
auto_method!(update_emoji_name(&str)@get_emoji_by_id:MANAGE_EMOJIS -> "UPDATE emojis SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.emoji:{}");
}

View file

@ -78,7 +78,11 @@ impl DataManager {
}
/// Get the post the given post is reposting (if some).
pub async fn get_post_reposting(&self, post: &Post) -> Option<(User, Post)> {
pub async fn get_post_reposting(
&self,
post: &Post,
ignore_users: &[usize],
) -> Option<(User, Post)> {
if let Some(ref repost) = post.context.repost {
if let Some(reposting) = repost.reposting {
let mut x = match self.get_post_by_id(reposting).await {
@ -86,6 +90,10 @@ impl DataManager {
Err(_) => return None,
};
if ignore_users.contains(&x.owner) {
return None;
}
x.mark_as_repost();
Some((
match self.get_user_by_id(x.owner).await {
@ -103,9 +111,18 @@ impl DataManager {
}
/// Get the question of a given post.
pub async fn get_post_question(&self, post: &Post) -> Result<Option<(Question, User)>> {
pub async fn get_post_question(
&self,
post: &Post,
ignore_users: &[usize],
) -> Result<Option<(Question, User)>> {
if post.context.answering != 0 {
let question = self.get_question_by_id(post.context.answering).await?;
if ignore_users.contains(&question.owner) {
return Ok(None);
}
let user = if question.owner == 0 {
User::anonymous()
} else {
@ -138,8 +155,8 @@ impl DataManager {
out.push((
post.clone(),
user.clone(),
self.get_post_reposting(&post).await,
self.get_post_question(&post).await?,
self.get_post_reposting(&post, ignore_users).await,
self.get_post_question(&post, ignore_users).await?,
));
} else {
let user = self.get_user_by_id(owner).await?;
@ -147,8 +164,8 @@ impl DataManager {
out.push((
post.clone(),
user,
self.get_post_reposting(&post).await,
self.get_post_question(&post).await?,
self.get_post_reposting(&post, ignore_users).await,
self.get_post_question(&post, ignore_users).await?,
));
}
}
@ -196,8 +213,8 @@ impl DataManager {
post.clone(),
user.clone(),
community.to_owned(),
self.get_post_reposting(&post).await,
self.get_post_question(&post).await?,
self.get_post_reposting(&post, ignore_users).await,
self.get_post_question(&post, ignore_users).await?,
));
} else {
let user = self.get_user_by_id(owner).await?;
@ -235,8 +252,8 @@ impl DataManager {
post.clone(),
user,
community,
self.get_post_reposting(&post).await,
self.get_post_question(&post).await?,
self.get_post_reposting(&post, ignore_users).await,
self.get_post_question(&post, ignore_users).await?,
));
}
}

View file

@ -1,10 +1,8 @@
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;
@ -56,7 +54,7 @@ impl DataManager {
///
/// # Arguments
/// * `data` - a mock [`MediaUpload`] object to insert
pub async fn create_upload(&self, data: MediaUpload) -> Result<()> {
pub async fn create_upload(&self, data: MediaUpload) -> Result<MediaUpload> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -78,7 +76,7 @@ impl DataManager {
}
// return
Ok(())
Ok(data)
}
pub async fn delete_upload(&self, id: usize) -> Result<()> {
@ -91,16 +89,8 @@ impl DataManager {
// 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()));
}
let upload = self.get_upload_by_id(id).await?;
upload.remove(&self.0)?;
// delete from database
let conn = match self.connect().await {

View file

@ -1,5 +1,9 @@
use pathbufd::PathBufD;
use serde::{Serialize, Deserialize};
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
use crate::config::Config;
use std::fs::{write, exists, remove_file};
use super::{Error, Result};
#[derive(Serialize, Deserialize)]
pub enum MediaType {
@ -15,6 +19,22 @@ pub enum MediaType {
Gif,
}
impl MediaType {
pub fn extension(&self) -> &str {
match self {
Self::Webp => "webp",
Self::Avif => "avif",
Self::Png => "png",
Self::Jpg => "jpg",
Self::Gif => "gif",
}
}
pub fn mime(&self) -> String {
format!("image/{}", self.extension())
}
}
#[derive(Serialize, Deserialize)]
pub struct MediaUpload {
pub id: usize,
@ -33,6 +53,35 @@ impl MediaUpload {
what,
}
}
/// Get the path to the fs file for this upload.
pub fn path(&self, config: &Config) -> PathBufD {
PathBufD::current()
.extend(&[config.dirs.media.as_str(), "uploads"])
.join(format!("{}.{}", self.id, self.what.extension()))
}
/// Write to this upload in the file system.
pub fn write(&self, config: &Config, bytes: &[u8]) -> Result<()> {
match write(self.path(config), bytes) {
Ok(_) => Ok(()),
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
/// Delete this upload in the file system.
pub fn remove(&self, config: &Config) -> Result<()> {
let path = self.path(config);
if !exists(&path).unwrap() {
return Ok(());
}
match remove_file(path) {
Ok(_) => Ok(()),
Err(e) => Err(Error::MiscError(e.to_string())),
}
}
}
#[derive(Serialize, Deserialize)]
@ -86,7 +135,7 @@ impl CustomEmoji {
out = out.replace(
&emoji.0,
&format!(
"<img class=\"emoji\" src=\"/api/v1/communities/{}/emoji/{}\" />",
"<img class=\"emoji\" src=\"/api/v1/communities/{}/emojis/{}\" />",
emoji.1, emoji.2
),
);

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-l10n"
version = "2.3.0"
version = "3.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-shared"
version = "2.3.0"
version = "3.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -37,7 +37,10 @@ pub fn render_markdown(input: &str) -> String {
.add_tag_attributes("a", &["href", "target"])
.clean(&html)
.to_string()
.replace("src=\"", "loading=\"lazy\" src=\"/api/v1/util/proxy?url=")
.replace(
"src=\"http",
"loading=\"lazy\" src=\"/api/v1/util/proxy?url=",
)
.replace("<video loading=", "<video controls loading=")
.replace("--&gt;", "<align class=\"right\">")
.replace("-&gt;", "<align class=\"center\">")