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:
parent
9f187039e6
commit
275dd0a1eb
25 changed files with 697 additions and 61 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto"
|
||||
version = "2.3.0"
|
||||
version = "3.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -311,6 +311,12 @@ img.contain {
|
|||
object-fit: contain;
|
||||
}
|
||||
|
||||
img.emoji {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
/* avatar/banner */
|
||||
.avatar {
|
||||
--size: 50px;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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", [
|
||||
|
|
|
@ -310,7 +310,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/stacks/{{ stack.id }}`, {
|
||||
fetch("/api/v1/stacks/{{ stack.id }}", {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
|
|
@ -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];
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -216,7 +216,7 @@ pub async fn logout_request(
|
|||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Goodbye!".to_string(),
|
||||
payload: (),
|
||||
payload: Some(user.username.clone()),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-core"
|
||||
version = "2.3.0"
|
||||
version = "3.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
|
|
@ -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:{}");
|
||||
}
|
||||
|
|
|
@ -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?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
),
|
||||
);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-l10n"
|
||||
version = "2.3.0"
|
||||
version = "3.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-shared"
|
||||
version = "2.3.0"
|
||||
version = "3.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -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("-->", "<align class=\"right\">")
|
||||
.replace("->", "<align class=\"center\">")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue