Add ability to delete user icon (#3383)

* Add user icon delete route

By request of moderation, but also just generally nice to have

* Add relevant docs and frontend

* Add v2 version
This commit is contained in:
Emma Alexia
2025-04-19 08:49:23 -04:00
committed by GitHub
parent 3cd6718384
commit 5c1f198397
6 changed files with 134 additions and 2 deletions

View File

@@ -1,7 +1,7 @@
openapi: '3.0.0'
info:
version: v2.7.0/15cf3fc
version: v2.7.0/366f528
title: Labrinth
termsOfService: https://modrinth.com/legal/terms
contact:
@@ -3018,6 +3018,24 @@ paths:
$ref: '#/components/schemas/InvalidInputError'
'404':
description: The requested item(s) were not found or no authorization to access the requested item(s)
delete:
summary: Remove user's avatar
operationId: deleteUserIcon
tags:
- users
security:
- TokenAuth: ['USER_WRITE']
responses:
'204':
description: Expected response to a valid request
'400':
description: Request was invalid, see given error
content:
application/json:
schema:
$ref: '#/components/schemas/InvalidInputError'
'404':
description: The requested item(s) were not found or no authorization to access the requested item(s)
/user/{id|username}/projects:
parameters:
- $ref: '#/components/parameters/UserIdentifier'

View File

@@ -32,6 +32,10 @@
>
<UploadIcon />
</FileInput>
<Button v-if="avatarUrl !== null" :action="removePreviewImage">
<TrashIcon />
{{ formatMessage(commonMessages.removeImageButton) }}
</Button>
<Button
v-if="previewImage"
:action="
@@ -86,7 +90,7 @@
</template>
<script setup>
import { UserIcon, SaveIcon, UploadIcon, UndoIcon, XIcon } from "@modrinth/assets";
import { UserIcon, SaveIcon, UploadIcon, UndoIcon, XIcon, TrashIcon } from "@modrinth/assets";
import { Avatar, FileInput, Button, commonMessages } from "@modrinth/ui";
useHead({
@@ -142,6 +146,7 @@ const bio = ref(auth.value.user.bio);
const avatarUrl = ref(auth.value.user.avatar_url);
const icon = shallowRef(null);
const previewImage = shallowRef(null);
const pendingAvatarDeletion = ref(false);
const saved = ref(false);
const hasUnsavedChanges = computed(
@@ -160,9 +165,15 @@ function showPreviewImage(files) {
};
}
function removePreviewImage() {
pendingAvatarDeletion.value = true;
previewImage.value = "https://cdn.modrinth.com/placeholder.png";
}
function cancel() {
icon.value = null;
previewImage.value = null;
pendingAvatarDeletion.value = false;
username.value = auth.value.user.username;
bio.value = auth.value.user.bio;
}
@@ -170,6 +181,14 @@ function cancel() {
async function saveChanges() {
startLoading();
try {
if (pendingAvatarDeletion.value) {
await useBaseFetch(`user/${auth.value.user.id}/icon`, {
method: "DELETE",
});
pendingAvatarDeletion.value = false;
previewImage.value = null;
}
if (icon.value) {
await useBaseFetch(
`user/${auth.value.user.id}/icon?ext=${

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE users\n SET avatar_url = NULL, raw_avatar_url = NULL\n WHERE (id = $1)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "483cb875ba81c7563a2f7220158cfcb9e6a117a4efc070438606e4c94103a9a4"
}

View File

@@ -27,6 +27,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(user_delete)
.service(user_edit)
.service(user_icon_edit)
.service(user_icon_delete)
.service(user_notifications)
.service(user_follows),
);
@@ -223,6 +224,28 @@ pub async fn user_icon_edit(
.or_else(v2_reroute::flatten_404_error)
}
#[delete("{id}/icon")]
pub async fn user_icon_delete(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so we don't need to convert to V2
v3::users::user_icon_delete(
req,
info,
pool,
redis,
file_host,
session_queue,
)
.await
.or_else(v2_reroute::flatten_404_error)
}
#[delete("{id}")]
pub async fn user_delete(
req: HttpRequest,

View File

@@ -38,6 +38,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route("{user_id}/organizations", web::get().to(orgs_list))
.route("{id}", web::patch().to(user_edit))
.route("{id}/icon", web::patch().to(user_icon_edit))
.route("{id}/icon", web::delete().to(user_icon_delete))
.route("{id}", web::delete().to(user_delete))
.route("{id}/follows", web::get().to(user_follows))
.route("{id}/notifications", web::get().to(user_notifications))
@@ -623,6 +624,59 @@ pub async fn user_icon_edit(
}
}
pub async fn user_icon_delete(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = id_option {
if user.id != actual_user.id.into() && !user.role.is_mod() {
return Err(ApiError::CustomAuthentication(
"You don't have permission to edit this user's icon."
.to_string(),
));
}
delete_old_images(
actual_user.avatar_url,
actual_user.raw_avatar_url,
&***file_host,
)
.await?;
sqlx::query!(
"
UPDATE users
SET avatar_url = NULL, raw_avatar_url = NULL
WHERE (id = $1)
",
actual_user.id as crate::database::models::ids::UserId,
)
.execute(&**pool)
.await?;
User::clear_caches(&[(actual_user.id, None)], &redis).await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::NotFound)
}
}
pub async fn user_delete(
req: HttpRequest,
info: web::Path<(String,)>,

View File

@@ -153,6 +153,10 @@ export const commonMessages = defineMessages({
id: 'button.upload-image',
defaultMessage: 'Upload image',
},
removeImageButton: {
id: 'button.remove-image',
defaultMessage: 'Remove image',
},
visibilityLabel: {
id: 'label.visibility',
defaultMessage: 'Visibility',