From 6bfbb9332dc48fbf09e27e21308d17b092a9ebdd Mon Sep 17 00:00:00 2001 From: Dessalines Date: Tue, 26 Mar 2024 12:06:11 -0400 Subject: [PATCH 1/2] Adding listMedia endpoint, to view all your local image uploads. (#4509) * Adding listMedia endpoint, to view all your local image uploads. - Fixes #4445 * Fix ts import. * Forgot to order by published desc * Adding an endpoint to list all images, for admins only. * Forgot to add file. * Add additional test. * Use better logic for no-limit version. * Better call sites. * Adding another test. * Fix tests. * Moving list_media to /account action. * Addressing PR comments. * Removing pointless comment. --------- Co-authored-by: SleeplessOne1917 <28871516+SleeplessOne1917@users.noreply.github.com> --- api_tests/package.json | 2 +- api_tests/pnpm-lock.yaml | 85 +------------------ api_tests/src/follow.spec.ts | 4 +- api_tests/src/image.spec.ts | 46 ++++++++-- api_tests/src/private_message.spec.ts | 4 +- api_tests/src/shared.ts | 18 ++++ crates/api/src/local_user/list_media.rs | 26 ++++++ crates/api/src/local_user/mod.rs | 1 + crates/api/src/site/list_all_media.rs | 24 ++++++ crates/api/src/site/mod.rs | 1 + crates/api/src/site/purge/person.rs | 2 +- crates/api_common/src/person.rs | 19 ++++- crates/db_schema/src/impls/images.rs | 71 ++++++++++++---- crates/db_schema/src/source/images.rs | 15 +++- .../db_views_actor/src/comment_reply_view.rs | 19 +++++ .../db_views_actor/src/person_mention_view.rs | 18 ++++ crates/db_views_actor/src/structs.rs | 2 + src/api_routes_http.rs | 9 ++ 18 files changed, 252 insertions(+), 114 deletions(-) create mode 100644 crates/api/src/local_user/list_media.rs create mode 100644 crates/api/src/site/list_all_media.rs diff --git a/api_tests/package.json b/api_tests/package.json index 07f9fa776..75ef362f8 100644 --- a/api_tests/package.json +++ b/api_tests/package.json @@ -27,7 +27,7 @@ "eslint": "^8.57.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.5.0", - "lemmy-js-client": "0.19.4-alpha.8", + "lemmy-js-client": "0.19.4-alpha.13", "prettier": "^3.2.5", "ts-jest": "^29.1.0", "typescript": "^5.4.2" diff --git a/api_tests/pnpm-lock.yaml b/api_tests/pnpm-lock.yaml index 39bba75a1..7b8c05328 100644 --- a/api_tests/pnpm-lock.yaml +++ b/api_tests/pnpm-lock.yaml @@ -30,8 +30,8 @@ devDependencies: specifier: ^29.5.0 version: 29.7.0(@types/node@20.11.27) lemmy-js-client: - specifier: 0.19.4-alpha.8 - version: 0.19.4-alpha.8 + specifier: 0.19.4-alpha.13 + version: 0.19.4-alpha.13 prettier: specifier: ^3.2.5 version: 3.2.5 @@ -1044,10 +1044,6 @@ packages: engines: {node: '>=8'} dev: true - /asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - dev: true - /babel-jest@29.7.0(@babel/core@7.23.9): resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1261,13 +1257,6 @@ packages: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true - /combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - dependencies: - delayed-stream: 1.0.0 - dev: true - /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true @@ -1295,14 +1284,6 @@ packages: - ts-node dev: true - /cross-fetch@4.0.0: - resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} - dependencies: - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - dev: true - /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -1342,11 +1323,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dev: true - /detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1646,15 +1622,6 @@ packages: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} dev: true - /form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} - engines: {node: '>= 6'} - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - mime-types: 2.1.35 - dev: true - /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true @@ -2390,13 +2357,8 @@ packages: engines: {node: '>=6'} dev: true - /lemmy-js-client@0.19.4-alpha.8: - resolution: {integrity: sha512-8vjqUYVOhyUTcmG9FvPLjrWziVwNa2/Zi+kSflTrajJsK0V+5DclJ5dhdVMUQ4DEA70gb0OuNMDlipPG2FoS5A==} - dependencies: - cross-fetch: 4.0.0 - form-data: 4.0.0 - transitivePeerDependencies: - - encoding + /lemmy-js-client@0.19.4-alpha.13: + resolution: {integrity: sha512-ru1dCqPSfOJdsGq7am5J7P7f+/hpyHGhNbCEV/JAZP2U1lGHul32gLpBkilDnStDNdeq52scjKx+3WskRJFGFA==} dev: true /leven@3.1.0: @@ -2485,18 +2447,6 @@ packages: picomatch: 2.3.1 dev: true - /mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - dev: true - - /mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - dependencies: - mime-db: 1.52.0 - dev: true - /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -2523,18 +2473,6 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - dependencies: - whatwg-url: 5.0.0 - dev: true - /node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} dev: true @@ -2952,10 +2890,6 @@ packages: is-number: 7.0.0 dev: true - /tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - dev: true - /ts-api-utils@1.3.0(typescript@5.4.2): resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} engines: {node: '>=16'} @@ -3067,17 +3001,6 @@ packages: makeerror: 1.0.12 dev: true - /webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - dev: true - - /whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - dev: true - /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} diff --git a/api_tests/src/follow.spec.ts b/api_tests/src/follow.spec.ts index 0187e3ee1..276213eac 100644 --- a/api_tests/src/follow.spec.ts +++ b/api_tests/src/follow.spec.ts @@ -5,18 +5,18 @@ import { setupLogins, resolveBetaCommunity, followCommunity, - unfollowRemotes, getSite, waitUntil, beta, betaUrl, registerUser, + unfollows, } from "./shared"; beforeAll(setupLogins); afterAll(() => { - unfollowRemotes(alpha); + unfollows(); }); test("Follow local community", async () => { diff --git a/api_tests/src/image.spec.ts b/api_tests/src/image.spec.ts index 6414a8913..7fd1bd47c 100644 --- a/api_tests/src/image.spec.ts +++ b/api_tests/src/image.spec.ts @@ -14,25 +14,30 @@ import { betaUrl, createCommunity, createPost, + deleteAllImages, delta, epsilon, gamma, getSite, + imageFetchLimit, registerUser, resolveBetaCommunity, resolvePost, setupLogins, - unfollowRemotes, + unfollows, } from "./shared"; const downloadFileSync = require("download-file-sync"); beforeAll(setupLogins); afterAll(() => { - unfollowRemotes(alphaImage); + unfollows(); }); test("Upload image and delete it", async () => { + // Before running this test, you need to delete all previous images in the DB + await deleteAllImages(alpha); + // Upload test image. We use a simple string buffer as pictrs doesnt require an actual image // in testing mode. const upload_form: UploadImage = { @@ -48,6 +53,24 @@ test("Upload image and delete it", async () => { const content = downloadFileSync(upload.url); expect(content.length).toBeGreaterThan(0); + // Ensure that it comes back with the list_media endpoint + const listMediaRes = await alphaImage.listMedia(); + expect(listMediaRes.images.length).toBe(1); + + // Ensure that it also comes back with the admin all images + const listAllMediaRes = await alphaImage.listAllMedia({ + limit: imageFetchLimit, + }); + + // This number comes from all the previous thumbnails fetched in other tests. + const previousThumbnails = 1; + expect(listAllMediaRes.images.length).toBe(previousThumbnails); + + // The deleteUrl is a combination of the endpoint, delete token, and alias + let firstImage = listMediaRes.images[0]; + let deleteUrl = `${alphaUrl}/pictrs/image/delete/${firstImage.pictrs_delete_token}/${firstImage.pictrs_alias}`; + expect(deleteUrl).toBe(upload.delete_url); + // delete image const delete_form: DeleteImage = { token: upload.files![0].delete_token, @@ -59,6 +82,16 @@ test("Upload image and delete it", async () => { // ensure that image is deleted const content2 = downloadFileSync(upload.url); expect(content2).toBe(""); + + // Ensure that it shows the image is deleted + const deletedListMediaRes = await alphaImage.listMedia(); + expect(deletedListMediaRes.images.length).toBe(0); + + // Ensure that the admin shows its deleted + const deletedListAllMediaRes = await alphaImage.listAllMedia({ + limit: imageFetchLimit, + }); + expect(deletedListAllMediaRes.images.length).toBe(previousThumbnails - 1); }); test("Purge user, uploaded image removed", async () => { @@ -80,10 +113,10 @@ test("Purge user, uploaded image removed", async () => { // purge user let site = await getSite(user); - const purge_form: PurgePerson = { + const purgeForm: PurgePerson = { person_id: site.my_user!.local_user_view.person.id, }; - const delete_ = await alphaImage.purgePerson(purge_form); + const delete_ = await alphaImage.purgePerson(purgeForm); expect(delete_.success).toBe(true); // ensure that image is deleted @@ -117,10 +150,11 @@ test("Purge post, linked image removed", async () => { expect(post.post_view.post.url).toBe(upload.url); // purge post - const purge_form: PurgePost = { + + const purgeForm: PurgePost = { post_id: post.post_view.post.id, }; - const delete_ = await beta.purgePost(purge_form); + const delete_ = await beta.purgePost(purgeForm); expect(delete_.success).toBe(true); // ensure that image is deleted diff --git a/api_tests/src/private_message.spec.ts b/api_tests/src/private_message.spec.ts index 75dcaee33..063ee05ee 100644 --- a/api_tests/src/private_message.spec.ts +++ b/api_tests/src/private_message.spec.ts @@ -8,9 +8,9 @@ import { editPrivateMessage, listPrivateMessages, deletePrivateMessage, - unfollowRemotes, waitUntil, reportPrivateMessage, + unfollows, } from "./shared"; let recipient_id: number; @@ -22,7 +22,7 @@ beforeAll(async () => { }); afterAll(() => { - unfollowRemotes(alpha); + unfollows(); }); test("Create a private message", async () => { diff --git a/api_tests/src/shared.ts b/api_tests/src/shared.ts index af2629393..723b63887 100644 --- a/api_tests/src/shared.ts +++ b/api_tests/src/shared.ts @@ -5,6 +5,7 @@ import { BlockInstanceResponse, CommunityId, CreatePrivateMessageReport, + DeleteImage, EditCommunity, GetReplies, GetRepliesResponse, @@ -79,6 +80,7 @@ import { GetPersonDetails } from "lemmy-js-client/dist/types/GetPersonDetails"; import { ListingType } from "lemmy-js-client/dist/types/ListingType"; export const fetchFunction = fetch; +export const imageFetchLimit = 50; export let alphaUrl = "http://127.0.0.1:8541"; export let betaUrl = "http://127.0.0.1:8551"; @@ -865,9 +867,25 @@ export function randomString(length: number): string { return result; } +export async function deleteAllImages(api: LemmyHttp) { + const imagesRes = await api.listAllMedia({ + limit: imageFetchLimit, + }); + imagesRes.images; + + for (const image of imagesRes.images) { + const form: DeleteImage = { + token: image.pictrs_delete_token, + filename: image.pictrs_alias, + }; + await api.deleteImage(form); + } +} + export async function unfollows() { await Promise.all([ unfollowRemotes(alpha), + unfollowRemotes(beta), unfollowRemotes(gamma), unfollowRemotes(delta), unfollowRemotes(epsilon), diff --git a/crates/api/src/local_user/list_media.rs b/crates/api/src/local_user/list_media.rs new file mode 100644 index 000000000..25df8a4c2 --- /dev/null +++ b/crates/api/src/local_user/list_media.rs @@ -0,0 +1,26 @@ +use actix_web::web::{Data, Json, Query}; +use lemmy_api_common::{ + context::LemmyContext, + person::{ListMedia, ListMediaResponse}, +}; +use lemmy_db_schema::source::images::LocalImage; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::error::LemmyError; + +#[tracing::instrument(skip(context))] +pub async fn list_media( + data: Query, + context: Data, + local_user_view: LocalUserView, +) -> Result, LemmyError> { + let page = data.page; + let limit = data.limit; + let images = LocalImage::get_all_paged_by_local_user_id( + &mut context.pool(), + local_user_view.local_user.id, + page, + limit, + ) + .await?; + Ok(Json(ListMediaResponse { images })) +} diff --git a/crates/api/src/local_user/mod.rs b/crates/api/src/local_user/mod.rs index 8bf2e5327..c00a4516e 100644 --- a/crates/api/src/local_user/mod.rs +++ b/crates/api/src/local_user/mod.rs @@ -10,6 +10,7 @@ pub mod generate_totp_secret; pub mod get_captcha; pub mod list_banned; pub mod list_logins; +pub mod list_media; pub mod login; pub mod logout; pub mod notifications; diff --git a/crates/api/src/site/list_all_media.rs b/crates/api/src/site/list_all_media.rs new file mode 100644 index 000000000..495e72e48 --- /dev/null +++ b/crates/api/src/site/list_all_media.rs @@ -0,0 +1,24 @@ +use actix_web::web::{Data, Json, Query}; +use lemmy_api_common::{ + context::LemmyContext, + person::{ListMedia, ListMediaResponse}, + utils::is_admin, +}; +use lemmy_db_schema::source::images::LocalImage; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::error::LemmyError; + +#[tracing::instrument(skip(context))] +pub async fn list_all_media( + data: Query, + context: Data, + local_user_view: LocalUserView, +) -> Result, LemmyError> { + // Only let admins view all media + is_admin(&local_user_view)?; + + let page = data.page; + let limit = data.limit; + let images = LocalImage::get_all(&mut context.pool(), page, limit).await?; + Ok(Json(ListMediaResponse { images })) +} diff --git a/crates/api/src/site/mod.rs b/crates/api/src/site/mod.rs index d63c77ad9..f18dea3d0 100644 --- a/crates/api/src/site/mod.rs +++ b/crates/api/src/site/mod.rs @@ -1,6 +1,7 @@ pub mod block; pub mod federated_instances; pub mod leave_admin; +pub mod list_all_media; pub mod mod_log; pub mod purge; pub mod registration_applications; diff --git a/crates/api/src/site/purge/person.rs b/crates/api/src/site/purge/person.rs index 130d04552..658846eab 100644 --- a/crates/api/src/site/purge/person.rs +++ b/crates/api/src/site/purge/person.rs @@ -32,7 +32,7 @@ pub async fn purge_person( // Read the local user to get their images, and delete them if let Ok(local_user) = LocalUserView::read_person(&mut context.pool(), data.person_id).await { let pictrs_uploads = - LocalImage::get_all_by_local_user_id(&mut context.pool(), &local_user.local_user.id).await?; + LocalImage::get_all_by_local_user_id(&mut context.pool(), local_user.local_user.id).await?; for upload in pictrs_uploads { delete_image_from_pictrs(&upload.pictrs_alias, &upload.pictrs_delete_token, &context) diff --git a/crates/api_common/src/person.rs b/crates/api_common/src/person.rs index 7af966164..a4f9b64d9 100644 --- a/crates/api_common/src/person.rs +++ b/crates/api_common/src/person.rs @@ -1,7 +1,7 @@ use crate::sensitive::Sensitive; use lemmy_db_schema::{ newtypes::{CommentReplyId, CommunityId, LanguageId, PersonId, PersonMentionId}, - source::site::Site, + source::{images::LocalImage, site::Site}, CommentSortType, ListingType, PostListingMode, @@ -422,3 +422,20 @@ pub struct UpdateTotp { pub struct UpdateTotpResponse { pub enabled: bool, } + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Get your user's image / media uploads. +pub struct ListMedia { + pub page: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +pub struct ListMediaResponse { + pub images: Vec, +} diff --git a/crates/db_schema/src/impls/images.rs b/crates/db_schema/src/impls/images.rs index a5982bd98..40d5c5853 100644 --- a/crates/db_schema/src/impls/images.rs +++ b/crates/db_schema/src/impls/images.rs @@ -1,11 +1,8 @@ use crate::{ newtypes::{DbUrl, LocalUserId}, - schema::{ - local_image::dsl::{local_image, local_user_id, pictrs_alias}, - remote_image::dsl::{link, remote_image}, - }, + schema::{local_image, remote_image}, source::images::{LocalImage, LocalImageForm, RemoteImage, RemoteImageForm}, - utils::{get_conn, DbPool}, + utils::{get_conn, limit_and_offset, DbPool}, }; use diesel::{ dsl::exists, @@ -15,7 +12,6 @@ use diesel::{ ExpressionMethods, NotFound, QueryDsl, - Table, }; use diesel_async::RunQueryDsl; use url::Url; @@ -23,27 +19,64 @@ use url::Url; impl LocalImage { pub async fn create(pool: &mut DbPool<'_>, form: &LocalImageForm) -> Result { let conn = &mut get_conn(pool).await?; - insert_into(local_image) + insert_into(local_image::table) .values(form) .get_result::(conn) .await } + pub async fn get_all_paged_by_local_user_id( + pool: &mut DbPool<'_>, + user_id: LocalUserId, + page: Option, + limit: Option, + ) -> Result, Error> { + Self::get_all_helper(pool, Some(user_id), page, limit, false).await + } + pub async fn get_all_by_local_user_id( pool: &mut DbPool<'_>, - user_id: &LocalUserId, + user_id: LocalUserId, + ) -> Result, Error> { + Self::get_all_helper(pool, Some(user_id), None, None, true).await + } + + pub async fn get_all( + pool: &mut DbPool<'_>, + page: Option, + limit: Option, + ) -> Result, Error> { + Self::get_all_helper(pool, None, page, limit, false).await + } + + async fn get_all_helper( + pool: &mut DbPool<'_>, + user_id: Option, + page: Option, + limit: Option, + ignore_page_limits: bool, ) -> Result, Error> { let conn = &mut get_conn(pool).await?; - local_image - .filter(local_user_id.eq(user_id)) - .select(local_image::all_columns()) - .load::(conn) - .await + let mut query = local_image::table + .select(local_image::all_columns) + .order_by(local_image::published.desc()) + .into_boxed(); + + if let Some(user_id) = user_id { + query = query.filter(local_image::local_user_id.eq(user_id)) + } + + if !ignore_page_limits { + let (limit, offset) = limit_and_offset(page, limit)?; + query = query.limit(limit).offset(offset); + } + + query.load::(conn).await } pub async fn delete_by_alias(pool: &mut DbPool<'_>, alias: &str) -> Result { let conn = &mut get_conn(pool).await?; - diesel::delete(local_image.filter(pictrs_alias.eq(alias))) + diesel::delete(local_image::table.filter(local_image::pictrs_alias.eq(alias))) .execute(conn) .await } @@ -56,7 +89,7 @@ impl RemoteImage { .into_iter() .map(|url| RemoteImageForm { link: url.into() }) .collect::>(); - insert_into(remote_image) + insert_into(remote_image::table) .values(forms) .on_conflict_do_nothing() .execute(conn) @@ -66,9 +99,11 @@ impl RemoteImage { pub async fn validate(pool: &mut DbPool<'_>, link_: DbUrl) -> Result<(), Error> { let conn = &mut get_conn(pool).await?; - let exists = select(exists(remote_image.filter((link).eq(link_)))) - .get_result::(conn) - .await?; + let exists = select(exists( + remote_image::table.filter(remote_image::link.eq(link_)), + )) + .get_result::(conn) + .await?; if exists { Ok(()) } else { diff --git a/crates/db_schema/src/source/images.rs b/crates/db_schema/src/source/images.rs index 3bf2a5bb8..da6be2a14 100644 --- a/crates/db_schema/src/source/images.rs +++ b/crates/db_schema/src/source/images.rs @@ -2,16 +2,27 @@ use crate::newtypes::{DbUrl, LocalUserId}; #[cfg(feature = "full")] use crate::schema::{local_image, remote_image}; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; use std::fmt::Debug; +#[cfg(feature = "full")] +use ts_rs::TS; use typed_builder::TypedBuilder; -#[derive(PartialEq, Eq, Debug, Clone)] -#[cfg_attr(feature = "full", derive(Queryable, Associations))] +#[skip_serializing_none] +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "full", + derive(Queryable, Selectable, Identifiable, Associations, TS) +)] +#[cfg_attr(feature = "full", ts(export))] #[cfg_attr(feature = "full", diesel(table_name = local_image))] #[cfg_attr( feature = "full", diesel(belongs_to(crate::source::local_user::LocalUser)) )] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "full", diesel(primary_key(local_user_id)))] pub struct LocalImage { pub local_user_id: Option, pub pictrs_alias: String, diff --git a/crates/db_views_actor/src/comment_reply_view.rs b/crates/db_views_actor/src/comment_reply_view.rs index 6d122291a..77f744a97 100644 --- a/crates/db_views_actor/src/comment_reply_view.rs +++ b/crates/db_views_actor/src/comment_reply_view.rs @@ -47,6 +47,16 @@ fn queries<'a>() -> Queries< ), ); + let is_local_user_banned_from_community = |person_id| { + exists( + community_person_ban::table.filter( + community::id + .eq(community_person_ban::community_id) + .and(community_person_ban::person_id.eq(person_id)), + ), + ) + }; + let is_saved = |person_id| { exists( comment_saved::table.filter( @@ -107,6 +117,14 @@ fn queries<'a>() -> Queries< let all_joins = move |query: comment_reply::BoxedQuery<'a, Pg>, my_person_id: Option| { + let is_local_user_banned_from_community_selection: Box< + dyn BoxableExpression<_, Pg, SqlType = sql_types::Bool>, + > = if let Some(person_id) = my_person_id { + Box::new(is_local_user_banned_from_community(person_id)) + } else { + Box::new(false.into_sql::()) + }; + let score_selection: Box< dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable>, > = if let Some(person_id) = my_person_id { @@ -153,6 +171,7 @@ fn queries<'a>() -> Queries< aliases::person1.fields(person::all_columns), comment_aggregates::all_columns, is_creator_banned_from_community, + is_local_user_banned_from_community_selection, creator_is_moderator, creator_is_admin, subscribed_type_selection, diff --git a/crates/db_views_actor/src/person_mention_view.rs b/crates/db_views_actor/src/person_mention_view.rs index 21a1bd0de..399850292 100644 --- a/crates/db_views_actor/src/person_mention_view.rs +++ b/crates/db_views_actor/src/person_mention_view.rs @@ -47,6 +47,16 @@ fn queries<'a>() -> Queries< ), ); + let is_local_user_banned_from_community = |person_id| { + exists( + community_person_ban::table.filter( + community::id + .eq(community_person_ban::community_id) + .and(community_person_ban::person_id.eq(person_id)), + ), + ) + }; + let is_saved = |person_id| { exists( comment_saved::table.filter( @@ -107,6 +117,13 @@ fn queries<'a>() -> Queries< let all_joins = move |query: person_mention::BoxedQuery<'a, Pg>, my_person_id: Option| { + let is_local_user_banned_from_community_selection: Box< + dyn BoxableExpression<_, Pg, SqlType = sql_types::Bool>, + > = if let Some(person_id) = my_person_id { + Box::new(is_local_user_banned_from_community(person_id)) + } else { + Box::new(false.into_sql::()) + }; let score_selection: Box< dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable>, > = if let Some(person_id) = my_person_id { @@ -153,6 +170,7 @@ fn queries<'a>() -> Queries< aliases::person1.fields(person::all_columns), comment_aggregates::all_columns, is_creator_banned_from_community, + is_local_user_banned_from_community_selection, creator_is_moderator, creator_is_admin, subscribed_type_selection, diff --git a/crates/db_views_actor/src/structs.rs b/crates/db_views_actor/src/structs.rs index f25662f7b..2356d2be4 100644 --- a/crates/db_views_actor/src/structs.rs +++ b/crates/db_views_actor/src/structs.rs @@ -108,6 +108,7 @@ pub struct PersonMentionView { pub recipient: Person, pub counts: CommentAggregates, pub creator_banned_from_community: bool, + pub banned_from_community: bool, pub creator_is_moderator: bool, pub creator_is_admin: bool, pub subscribed: SubscribedType, @@ -131,6 +132,7 @@ pub struct CommentReplyView { pub recipient: Person, pub counts: CommentAggregates, pub creator_banned_from_community: bool, + pub banned_from_community: bool, pub creator_is_moderator: bool, pub creator_is_admin: bool, pub subscribed: SubscribedType, diff --git a/src/api_routes_http.rs b/src/api_routes_http.rs index 966862fa5..013e2e092 100644 --- a/src/api_routes_http.rs +++ b/src/api_routes_http.rs @@ -29,6 +29,7 @@ use lemmy_api::{ get_captcha::get_captcha, list_banned::list_banned_users, list_logins::list_logins, + list_media::list_media, login::login, logout::logout, notifications::{ @@ -71,6 +72,7 @@ use lemmy_api::{ block::block_instance, federated_instances::get_federated_instances, leave_admin::leave_admin, + list_all_media::list_all_media, mod_log::get_mod_log, purge::{ comment::purge_comment, @@ -282,6 +284,12 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { .wrap(rate_limit.import_user_settings()) .route(web::post().to(import_settings)), ) + // TODO, all the current account related actions under /user need to get moved here eventually + .service( + web::scope("/account") + .wrap(rate_limit.message()) + .route("/list_media", web::get().to(list_media)), + ) // User actions .service( web::scope("/user") @@ -339,6 +347,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { "/registration_application/approve", web::put().to(approve_registration_application), ) + .route("/list_all_media", web::get().to(list_all_media)) .service( web::scope("/purge") .route("/person", web::post().to(purge_person)) From 85ee89f4e802cd5034558208e8e932d70cf30620 Mon Sep 17 00:00:00 2001 From: Nutomic Date: Wed, 27 Mar 2024 14:00:52 +0100 Subject: [PATCH 2/2] When uploading new icon/avatar/banner, delete old one (#4573) --- api_tests/src/user.spec.ts | 34 +++++++++++++++++++++- crates/api/src/local_user/save_settings.rs | 6 +++- crates/api_common/src/request.rs | 21 +++++++++++++ crates/api_crud/src/community/update.rs | 4 +++ crates/api_crud/src/site/update.rs | 7 ++++- crates/db_schema/src/impls/images.rs | 9 ++++-- 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/api_tests/src/user.spec.ts b/api_tests/src/user.spec.ts index 73f3f3942..5af054918 100644 --- a/api_tests/src/user.spec.ts +++ b/api_tests/src/user.spec.ts @@ -19,8 +19,9 @@ import { getPost, getComments, fetchFunction, + alphaImage, } from "./shared"; -import { LemmyHttp, SaveUserSettings } from "lemmy-js-client"; +import { LemmyHttp, SaveUserSettings, UploadImage } from "lemmy-js-client"; import { GetPosts } from "lemmy-js-client/dist/types/GetPosts"; beforeAll(setupLogins); @@ -159,3 +160,34 @@ test("Create user with accept-language", async () => { // which is automatically enabled by backend expect(langs).toStrictEqual(["und", "de", "en", "fr"]); }); + +test("Set a new avatar, old avatar is deleted", async () => { + const listMediaRes = await alphaImage.listMedia(); + expect(listMediaRes.images.length).toBe(0); + const upload_form1: UploadImage = { + image: Buffer.from("test1"), + }; + const upload1 = await alphaImage.uploadImage(upload_form1); + expect(upload1.url).toBeDefined(); + + let form1 = { + avatar: upload1.url, + }; + await saveUserSettings(alpha, form1); + const listMediaRes1 = await alphaImage.listMedia(); + expect(listMediaRes1.images.length).toBe(1); + + const upload_form2: UploadImage = { + image: Buffer.from("test2"), + }; + const upload2 = await alphaImage.uploadImage(upload_form2); + expect(upload2.url).toBeDefined(); + + let form2 = { + avatar: upload1.url, + }; + await saveUserSettings(alpha, form2); + // make sure only the new avatar is kept + const listMediaRes2 = await alphaImage.listMedia(); + expect(listMediaRes2.images.length).toBe(1); +}); diff --git a/crates/api/src/local_user/save_settings.rs b/crates/api/src/local_user/save_settings.rs index 927496416..972760a00 100644 --- a/crates/api/src/local_user/save_settings.rs +++ b/crates/api/src/local_user/save_settings.rs @@ -1,7 +1,9 @@ -use actix_web::web::{Data, Json}; +use activitypub_federation::config::Data; +use actix_web::web::Json; use lemmy_api_common::{ context::LemmyContext, person::SaveUserSettings, + request::replace_image, utils::{ get_url_blocklist, local_site_to_slur_regex, @@ -40,6 +42,8 @@ pub async fn save_user_settings( let bio = diesel_option_overwrite( process_markdown_opt(&data.bio, &slur_regex, &url_blocklist, &context).await?, ); + replace_image(&data.avatar, &local_user_view.person.avatar, &context).await?; + replace_image(&data.banner, &local_user_view.person.banner, &context).await?; let avatar = proxy_image_link_opt_api(&data.avatar, &context).await?; let banner = proxy_image_link_opt_api(&data.banner, &context).await?; diff --git a/crates/api_common/src/request.rs b/crates/api_common/src/request.rs index 74ee9ee47..7209c5871 100644 --- a/crates/api_common/src/request.rs +++ b/crates/api_common/src/request.rs @@ -3,6 +3,7 @@ use crate::{ post::{LinkMetadata, OpenGraphData}, utils::proxy_image_link, }; +use activitypub_federation::config::Data; use encoding::{all::encodings, DecoderTrap}; use lemmy_db_schema::{ newtypes::DbUrl, @@ -312,6 +313,26 @@ async fn is_image_content_type(client: &ClientWithMiddleware, url: &Url) -> Resu } } +/// When adding a new avatar or similar image, delete the old one. +pub async fn replace_image( + new_image: &Option, + old_image: &Option, + context: &Data, +) -> Result<(), LemmyError> { + if new_image.is_some() { + // Ignore errors because image may be stored externally. + if let Some(avatar) = &old_image { + let image = LocalImage::delete_by_url(&mut context.pool(), avatar) + .await + .ok(); + if let Some(image) = image { + delete_image_from_pictrs(&image.pictrs_alias, &image.pictrs_delete_token, context).await?; + } + } + } + Ok(()) +} + #[cfg(test)] #[allow(clippy::unwrap_used)] #[allow(clippy::indexing_slicing)] diff --git a/crates/api_crud/src/community/update.rs b/crates/api_crud/src/community/update.rs index 83ffded13..51c57e1c8 100644 --- a/crates/api_crud/src/community/update.rs +++ b/crates/api_crud/src/community/update.rs @@ -4,6 +4,7 @@ use lemmy_api_common::{ build_response::build_community_response, community::{CommunityResponse, EditCommunity}, context::LemmyContext, + request::replace_image, send_activity::{ActivityChannel, SendActivityData}, utils::{ check_community_mod_action, @@ -42,6 +43,9 @@ pub async fn update_community( let description = process_markdown_opt(&data.description, &slur_regex, &url_blocklist, &context).await?; is_valid_body_field(&data.description, false)?; + let old_community = Community::read(&mut context.pool(), data.community_id).await?; + replace_image(&data.icon, &old_community.icon, &context).await?; + replace_image(&data.banner, &old_community.banner, &context).await?; let description = diesel_option_overwrite(description); let icon = proxy_image_link_opt_api(&data.icon, &context).await?; diff --git a/crates/api_crud/src/site/update.rs b/crates/api_crud/src/site/update.rs index 6d419a6d8..530dbb47f 100644 --- a/crates/api_crud/src/site/update.rs +++ b/crates/api_crud/src/site/update.rs @@ -1,7 +1,9 @@ use crate::site::{application_question_check, site_default_post_listing_type_check}; -use actix_web::web::{Data, Json}; +use activitypub_federation::config::Data; +use actix_web::web::Json; use lemmy_api_common::{ context::LemmyContext, + request::replace_image, site::{EditSite, SiteResponse}, utils::{ get_url_blocklist, @@ -63,6 +65,9 @@ pub async fn update_site( SiteLanguage::update(&mut context.pool(), discussion_languages.clone(), &site).await?; } + replace_image(&data.icon, &site.icon, &context).await?; + replace_image(&data.banner, &site.banner, &context).await?; + let slur_regex = local_site_to_slur_regex(&local_site); let url_blocklist = get_url_blocklist(&context).await?; let sidebar = process_markdown_opt(&data.sidebar, &slur_regex, &url_blocklist, &context).await?; diff --git a/crates/db_schema/src/impls/images.rs b/crates/db_schema/src/impls/images.rs index 40d5c5853..a9aeb1f16 100644 --- a/crates/db_schema/src/impls/images.rs +++ b/crates/db_schema/src/impls/images.rs @@ -74,12 +74,17 @@ impl LocalImage { query.load::(conn).await } - pub async fn delete_by_alias(pool: &mut DbPool<'_>, alias: &str) -> Result { + pub async fn delete_by_alias(pool: &mut DbPool<'_>, alias: &str) -> Result { let conn = &mut get_conn(pool).await?; diesel::delete(local_image::table.filter(local_image::pictrs_alias.eq(alias))) - .execute(conn) + .get_result(conn) .await } + + pub async fn delete_by_url(pool: &mut DbPool<'_>, url: &DbUrl) -> Result { + let alias = url.as_str().split('/').last().ok_or(NotFound)?; + Self::delete_by_alias(pool, alias).await + } } impl RemoteImage {