Merge remote-tracking branch 'origin/main' into delete_local_image_on_delete_account

delete_local_image_on_delete_account
Dessalines 2024-03-27 09:56:13 -04:00
commit ac2ebc378b
22 changed files with 328 additions and 119 deletions

View File

@ -27,7 +27,7 @@
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"jest": "^29.5.0", "jest": "^29.5.0",
"lemmy-js-client": "0.19.4-alpha.8", "lemmy-js-client": "0.19.4-alpha.13",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",
"typescript": "^5.4.2" "typescript": "^5.4.2"

View File

@ -30,8 +30,8 @@ devDependencies:
specifier: ^29.5.0 specifier: ^29.5.0
version: 29.7.0(@types/node@20.11.27) version: 29.7.0(@types/node@20.11.27)
lemmy-js-client: lemmy-js-client:
specifier: 0.19.4-alpha.8 specifier: 0.19.4-alpha.13
version: 0.19.4-alpha.8 version: 0.19.4-alpha.13
prettier: prettier:
specifier: ^3.2.5 specifier: ^3.2.5
version: 3.2.5 version: 3.2.5
@ -1044,10 +1044,6 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
dev: true 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): /babel-jest@29.7.0(@babel/core@7.23.9):
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -1261,13 +1257,6 @@ packages:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
dev: true 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: /concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true dev: true
@ -1295,14 +1284,6 @@ packages:
- ts-node - ts-node
dev: true 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: /cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -1342,11 +1323,6 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true dev: true
/delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dev: true
/detect-newline@3.1.0: /detect-newline@3.1.0:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1646,15 +1622,6 @@ packages:
resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==}
dev: true 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: /fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: true dev: true
@ -2390,13 +2357,8 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: true dev: true
/lemmy-js-client@0.19.4-alpha.8: /lemmy-js-client@0.19.4-alpha.13:
resolution: {integrity: sha512-8vjqUYVOhyUTcmG9FvPLjrWziVwNa2/Zi+kSflTrajJsK0V+5DclJ5dhdVMUQ4DEA70gb0OuNMDlipPG2FoS5A==} resolution: {integrity: sha512-ru1dCqPSfOJdsGq7am5J7P7f+/hpyHGhNbCEV/JAZP2U1lGHul32gLpBkilDnStDNdeq52scjKx+3WskRJFGFA==}
dependencies:
cross-fetch: 4.0.0
form-data: 4.0.0
transitivePeerDependencies:
- encoding
dev: true dev: true
/leven@3.1.0: /leven@3.1.0:
@ -2485,18 +2447,6 @@ packages:
picomatch: 2.3.1 picomatch: 2.3.1
dev: true 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: /mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -2523,18 +2473,6 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true 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: /node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
dev: true dev: true
@ -2952,10 +2890,6 @@ packages:
is-number: 7.0.0 is-number: 7.0.0
dev: true dev: true
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: true
/ts-api-utils@1.3.0(typescript@5.4.2): /ts-api-utils@1.3.0(typescript@5.4.2):
resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
@ -3067,17 +3001,6 @@ packages:
makeerror: 1.0.12 makeerror: 1.0.12
dev: true 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: /which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}

View File

@ -5,18 +5,18 @@ import {
setupLogins, setupLogins,
resolveBetaCommunity, resolveBetaCommunity,
followCommunity, followCommunity,
unfollowRemotes,
getSite, getSite,
waitUntil, waitUntil,
beta, beta,
betaUrl, betaUrl,
registerUser, registerUser,
unfollows,
} from "./shared"; } from "./shared";
beforeAll(setupLogins); beforeAll(setupLogins);
afterAll(() => { afterAll(() => {
unfollowRemotes(alpha); unfollows();
}); });
test("Follow local community", async () => { test("Follow local community", async () => {

View File

@ -14,25 +14,30 @@ import {
betaUrl, betaUrl,
createCommunity, createCommunity,
createPost, createPost,
deleteAllImages,
delta, delta,
epsilon, epsilon,
gamma, gamma,
getSite, getSite,
imageFetchLimit,
registerUser, registerUser,
resolveBetaCommunity, resolveBetaCommunity,
resolvePost, resolvePost,
setupLogins, setupLogins,
unfollowRemotes, unfollows,
} from "./shared"; } from "./shared";
const downloadFileSync = require("download-file-sync"); const downloadFileSync = require("download-file-sync");
beforeAll(setupLogins); beforeAll(setupLogins);
afterAll(() => { afterAll(() => {
unfollowRemotes(alphaImage); unfollows();
}); });
test("Upload image and delete it", async () => { 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 // Upload test image. We use a simple string buffer as pictrs doesnt require an actual image
// in testing mode. // in testing mode.
const upload_form: UploadImage = { const upload_form: UploadImage = {
@ -48,6 +53,24 @@ test("Upload image and delete it", async () => {
const content = downloadFileSync(upload.url); const content = downloadFileSync(upload.url);
expect(content.length).toBeGreaterThan(0); 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 // delete image
const delete_form: DeleteImage = { const delete_form: DeleteImage = {
token: upload.files![0].delete_token, token: upload.files![0].delete_token,
@ -59,6 +82,16 @@ test("Upload image and delete it", async () => {
// ensure that image is deleted // ensure that image is deleted
const content2 = downloadFileSync(upload.url); const content2 = downloadFileSync(upload.url);
expect(content2).toBe(""); 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 () => { test("Purge user, uploaded image removed", async () => {
@ -80,10 +113,10 @@ test("Purge user, uploaded image removed", async () => {
// purge user // purge user
let site = await getSite(user); let site = await getSite(user);
const purge_form: PurgePerson = { const purgeForm: PurgePerson = {
person_id: site.my_user!.local_user_view.person.id, 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); expect(delete_.success).toBe(true);
// ensure that image is deleted // 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); expect(post.post_view.post.url).toBe(upload.url);
// purge post // purge post
const purge_form: PurgePost = {
const purgeForm: PurgePost = {
post_id: post.post_view.post.id, 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); expect(delete_.success).toBe(true);
// ensure that image is deleted // ensure that image is deleted

View File

@ -8,9 +8,9 @@ import {
editPrivateMessage, editPrivateMessage,
listPrivateMessages, listPrivateMessages,
deletePrivateMessage, deletePrivateMessage,
unfollowRemotes,
waitUntil, waitUntil,
reportPrivateMessage, reportPrivateMessage,
unfollows,
} from "./shared"; } from "./shared";
let recipient_id: number; let recipient_id: number;
@ -22,7 +22,7 @@ beforeAll(async () => {
}); });
afterAll(() => { afterAll(() => {
unfollowRemotes(alpha); unfollows();
}); });
test("Create a private message", async () => { test("Create a private message", async () => {

View File

@ -5,6 +5,7 @@ import {
BlockInstanceResponse, BlockInstanceResponse,
CommunityId, CommunityId,
CreatePrivateMessageReport, CreatePrivateMessageReport,
DeleteImage,
EditCommunity, EditCommunity,
GetReplies, GetReplies,
GetRepliesResponse, GetRepliesResponse,
@ -79,6 +80,7 @@ import { GetPersonDetails } from "lemmy-js-client/dist/types/GetPersonDetails";
import { ListingType } from "lemmy-js-client/dist/types/ListingType"; import { ListingType } from "lemmy-js-client/dist/types/ListingType";
export const fetchFunction = fetch; export const fetchFunction = fetch;
export const imageFetchLimit = 50;
export let alphaUrl = "http://127.0.0.1:8541"; export let alphaUrl = "http://127.0.0.1:8541";
export let betaUrl = "http://127.0.0.1:8551"; export let betaUrl = "http://127.0.0.1:8551";
@ -865,9 +867,25 @@ export function randomString(length: number): string {
return result; 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() { export async function unfollows() {
await Promise.all([ await Promise.all([
unfollowRemotes(alpha), unfollowRemotes(alpha),
unfollowRemotes(beta),
unfollowRemotes(gamma), unfollowRemotes(gamma),
unfollowRemotes(delta), unfollowRemotes(delta),
unfollowRemotes(epsilon), unfollowRemotes(epsilon),

View File

@ -19,8 +19,9 @@ import {
getPost, getPost,
getComments, getComments,
fetchFunction, fetchFunction,
alphaImage,
} from "./shared"; } 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"; import { GetPosts } from "lemmy-js-client/dist/types/GetPosts";
beforeAll(setupLogins); beforeAll(setupLogins);
@ -159,3 +160,34 @@ test("Create user with accept-language", async () => {
// which is automatically enabled by backend // which is automatically enabled by backend
expect(langs).toStrictEqual(["und", "de", "en", "fr"]); 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);
});

View File

@ -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<ListMedia>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> Result<Json<ListMediaResponse>, 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 }))
}

View File

@ -10,6 +10,7 @@ pub mod generate_totp_secret;
pub mod get_captcha; pub mod get_captcha;
pub mod list_banned; pub mod list_banned;
pub mod list_logins; pub mod list_logins;
pub mod list_media;
pub mod login; pub mod login;
pub mod logout; pub mod logout;
pub mod notifications; pub mod notifications;

View File

@ -1,7 +1,9 @@
use actix_web::web::{Data, Json}; use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{ use lemmy_api_common::{
context::LemmyContext, context::LemmyContext,
person::SaveUserSettings, person::SaveUserSettings,
request::replace_image,
utils::{ utils::{
get_url_blocklist, get_url_blocklist,
local_site_to_slur_regex, local_site_to_slur_regex,
@ -40,6 +42,8 @@ pub async fn save_user_settings(
let bio = diesel_option_overwrite( let bio = diesel_option_overwrite(
process_markdown_opt(&data.bio, &slur_regex, &url_blocklist, &context).await?, 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 avatar = proxy_image_link_opt_api(&data.avatar, &context).await?;
let banner = proxy_image_link_opt_api(&data.banner, &context).await?; let banner = proxy_image_link_opt_api(&data.banner, &context).await?;

View File

@ -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<ListMedia>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> Result<Json<ListMediaResponse>, 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 }))
}

View File

@ -1,6 +1,7 @@
pub mod block; pub mod block;
pub mod federated_instances; pub mod federated_instances;
pub mod leave_admin; pub mod leave_admin;
pub mod list_all_media;
pub mod mod_log; pub mod mod_log;
pub mod purge; pub mod purge;
pub mod registration_applications; pub mod registration_applications;

View File

@ -1,7 +1,7 @@
use crate::sensitive::Sensitive; use crate::sensitive::Sensitive;
use lemmy_db_schema::{ use lemmy_db_schema::{
newtypes::{CommentReplyId, CommunityId, LanguageId, PersonId, PersonMentionId}, newtypes::{CommentReplyId, CommunityId, LanguageId, PersonId, PersonMentionId},
source::site::Site, source::{images::LocalImage, site::Site},
CommentSortType, CommentSortType,
ListingType, ListingType,
PostListingMode, PostListingMode,
@ -422,3 +422,20 @@ pub struct UpdateTotp {
pub struct UpdateTotpResponse { pub struct UpdateTotpResponse {
pub enabled: bool, 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<i64>,
pub limit: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "full", derive(TS))]
#[cfg_attr(feature = "full", ts(export))]
pub struct ListMediaResponse {
pub images: Vec<LocalImage>,
}

View File

@ -3,6 +3,7 @@ use crate::{
post::{LinkMetadata, OpenGraphData}, post::{LinkMetadata, OpenGraphData},
utils::proxy_image_link, utils::proxy_image_link,
}; };
use activitypub_federation::config::Data;
use encoding::{all::encodings, DecoderTrap}; use encoding::{all::encodings, DecoderTrap};
use lemmy_db_schema::{ use lemmy_db_schema::{
newtypes::DbUrl, 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<String>,
old_image: &Option<DbUrl>,
context: &Data<LemmyContext>,
) -> 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)] #[cfg(test)]
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
#[allow(clippy::indexing_slicing)] #[allow(clippy::indexing_slicing)]

View File

@ -4,6 +4,7 @@ use lemmy_api_common::{
build_response::build_community_response, build_response::build_community_response,
community::{CommunityResponse, EditCommunity}, community::{CommunityResponse, EditCommunity},
context::LemmyContext, context::LemmyContext,
request::replace_image,
send_activity::{ActivityChannel, SendActivityData}, send_activity::{ActivityChannel, SendActivityData},
utils::{ utils::{
check_community_mod_action, check_community_mod_action,
@ -42,6 +43,9 @@ pub async fn update_community(
let description = let description =
process_markdown_opt(&data.description, &slur_regex, &url_blocklist, &context).await?; process_markdown_opt(&data.description, &slur_regex, &url_blocklist, &context).await?;
is_valid_body_field(&data.description, false)?; 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 description = diesel_option_overwrite(description);
let icon = proxy_image_link_opt_api(&data.icon, &context).await?; let icon = proxy_image_link_opt_api(&data.icon, &context).await?;

View File

@ -1,7 +1,9 @@
use crate::site::{application_question_check, site_default_post_listing_type_check}; 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::{ use lemmy_api_common::{
context::LemmyContext, context::LemmyContext,
request::replace_image,
site::{EditSite, SiteResponse}, site::{EditSite, SiteResponse},
utils::{ utils::{
get_url_blocklist, get_url_blocklist,
@ -63,6 +65,9 @@ pub async fn update_site(
SiteLanguage::update(&mut context.pool(), discussion_languages.clone(), &site).await?; 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 slur_regex = local_site_to_slur_regex(&local_site);
let url_blocklist = get_url_blocklist(&context).await?; let url_blocklist = get_url_blocklist(&context).await?;
let sidebar = process_markdown_opt(&data.sidebar, &slur_regex, &url_blocklist, &context).await?; let sidebar = process_markdown_opt(&data.sidebar, &slur_regex, &url_blocklist, &context).await?;

View File

@ -1,11 +1,8 @@
use crate::{ use crate::{
newtypes::{DbUrl, LocalUserId}, newtypes::{DbUrl, LocalUserId},
schema::{ schema::{local_image, remote_image},
local_image::dsl::{local_image, local_user_id, pictrs_alias},
remote_image::dsl::{link, remote_image},
},
source::images::{LocalImage, LocalImageForm, RemoteImage, RemoteImageForm}, source::images::{LocalImage, LocalImageForm, RemoteImage, RemoteImageForm},
utils::{get_conn, DbPool}, utils::{get_conn, limit_and_offset, DbPool},
}; };
use diesel::{ use diesel::{
dsl::exists, dsl::exists,
@ -15,7 +12,6 @@ use diesel::{
ExpressionMethods, ExpressionMethods,
NotFound, NotFound,
QueryDsl, QueryDsl,
Table,
}; };
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use url::Url; use url::Url;
@ -23,29 +19,71 @@ use url::Url;
impl LocalImage { impl LocalImage {
pub async fn create(pool: &mut DbPool<'_>, form: &LocalImageForm) -> Result<Self, Error> { pub async fn create(pool: &mut DbPool<'_>, form: &LocalImageForm) -> Result<Self, Error> {
let conn = &mut get_conn(pool).await?; let conn = &mut get_conn(pool).await?;
insert_into(local_image) insert_into(local_image::table)
.values(form) .values(form)
.get_result::<Self>(conn) .get_result::<Self>(conn)
.await .await
} }
pub async fn get_all_paged_by_local_user_id(
pool: &mut DbPool<'_>,
user_id: LocalUserId,
page: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Self>, Error> {
Self::get_all_helper(pool, Some(user_id), page, limit, false).await
}
pub async fn get_all_by_local_user_id( pub async fn get_all_by_local_user_id(
pool: &mut DbPool<'_>, pool: &mut DbPool<'_>,
user_id: &LocalUserId, user_id: LocalUserId,
) -> Result<Vec<Self>, Error> {
Self::get_all_helper(pool, Some(user_id), None, None, true).await
}
pub async fn get_all(
pool: &mut DbPool<'_>,
page: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Self>, Error> {
Self::get_all_helper(pool, None, page, limit, false).await
}
async fn get_all_helper(
pool: &mut DbPool<'_>,
user_id: Option<LocalUserId>,
page: Option<i64>,
limit: Option<i64>,
ignore_page_limits: bool,
) -> Result<Vec<Self>, Error> { ) -> Result<Vec<Self>, Error> {
let conn = &mut get_conn(pool).await?; let conn = &mut get_conn(pool).await?;
local_image let mut query = local_image::table
.filter(local_user_id.eq(user_id)) .select(local_image::all_columns)
.select(local_image::all_columns()) .order_by(local_image::published.desc())
.load::<LocalImage>(conn) .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::<LocalImage>(conn).await
}
pub async fn delete_by_alias(pool: &mut DbPool<'_>, alias: &str) -> Result<Self, Error> {
let conn = &mut get_conn(pool).await?;
diesel::delete(local_image::table.filter(local_image::pictrs_alias.eq(alias)))
.get_result(conn)
.await .await
} }
pub async fn delete_by_alias(pool: &mut DbPool<'_>, alias: &str) -> Result<usize, Error> { pub async fn delete_by_url(pool: &mut DbPool<'_>, url: &DbUrl) -> Result<Self, Error> {
let conn = &mut get_conn(pool).await?; let alias = url.as_str().split('/').last().ok_or(NotFound)?;
diesel::delete(local_image.filter(pictrs_alias.eq(alias))) Self::delete_by_alias(pool, alias).await
.execute(conn)
.await
} }
} }
@ -56,7 +94,7 @@ impl RemoteImage {
.into_iter() .into_iter()
.map(|url| RemoteImageForm { link: url.into() }) .map(|url| RemoteImageForm { link: url.into() })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
insert_into(remote_image) insert_into(remote_image::table)
.values(forms) .values(forms)
.on_conflict_do_nothing() .on_conflict_do_nothing()
.execute(conn) .execute(conn)
@ -66,9 +104,11 @@ impl RemoteImage {
pub async fn validate(pool: &mut DbPool<'_>, link_: DbUrl) -> Result<(), Error> { pub async fn validate(pool: &mut DbPool<'_>, link_: DbUrl) -> Result<(), Error> {
let conn = &mut get_conn(pool).await?; let conn = &mut get_conn(pool).await?;
let exists = select(exists(remote_image.filter((link).eq(link_)))) let exists = select(exists(
.get_result::<bool>(conn) remote_image::table.filter(remote_image::link.eq(link_)),
.await?; ))
.get_result::<bool>(conn)
.await?;
if exists { if exists {
Ok(()) Ok(())
} else { } else {

View File

@ -2,16 +2,27 @@ use crate::newtypes::{DbUrl, LocalUserId};
#[cfg(feature = "full")] #[cfg(feature = "full")]
use crate::schema::{local_image, remote_image}; use crate::schema::{local_image, remote_image};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::fmt::Debug; use std::fmt::Debug;
#[cfg(feature = "full")]
use ts_rs::TS;
use typed_builder::TypedBuilder; use typed_builder::TypedBuilder;
#[derive(PartialEq, Eq, Debug, Clone)] #[skip_serializing_none]
#[cfg_attr(feature = "full", derive(Queryable, Associations))] #[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(table_name = local_image))]
#[cfg_attr( #[cfg_attr(
feature = "full", feature = "full",
diesel(belongs_to(crate::source::local_user::LocalUser)) 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 struct LocalImage {
pub local_user_id: Option<LocalUserId>, pub local_user_id: Option<LocalUserId>,
pub pictrs_alias: String, pub pictrs_alias: String,

View File

@ -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| { let is_saved = |person_id| {
exists( exists(
comment_saved::table.filter( comment_saved::table.filter(
@ -107,6 +117,14 @@ fn queries<'a>() -> Queries<
let all_joins = move |query: comment_reply::BoxedQuery<'a, Pg>, let all_joins = move |query: comment_reply::BoxedQuery<'a, Pg>,
my_person_id: Option<PersonId>| { my_person_id: Option<PersonId>| {
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::<sql_types::Bool>())
};
let score_selection: Box< let score_selection: Box<
dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable<sql_types::SmallInt>>, dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable<sql_types::SmallInt>>,
> = if let Some(person_id) = my_person_id { > = if let Some(person_id) = my_person_id {
@ -153,6 +171,7 @@ fn queries<'a>() -> Queries<
aliases::person1.fields(person::all_columns), aliases::person1.fields(person::all_columns),
comment_aggregates::all_columns, comment_aggregates::all_columns,
is_creator_banned_from_community, is_creator_banned_from_community,
is_local_user_banned_from_community_selection,
creator_is_moderator, creator_is_moderator,
creator_is_admin, creator_is_admin,
subscribed_type_selection, subscribed_type_selection,

View File

@ -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| { let is_saved = |person_id| {
exists( exists(
comment_saved::table.filter( comment_saved::table.filter(
@ -107,6 +117,13 @@ fn queries<'a>() -> Queries<
let all_joins = move |query: person_mention::BoxedQuery<'a, Pg>, let all_joins = move |query: person_mention::BoxedQuery<'a, Pg>,
my_person_id: Option<PersonId>| { my_person_id: Option<PersonId>| {
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::<sql_types::Bool>())
};
let score_selection: Box< let score_selection: Box<
dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable<sql_types::SmallInt>>, dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable<sql_types::SmallInt>>,
> = if let Some(person_id) = my_person_id { > = if let Some(person_id) = my_person_id {
@ -153,6 +170,7 @@ fn queries<'a>() -> Queries<
aliases::person1.fields(person::all_columns), aliases::person1.fields(person::all_columns),
comment_aggregates::all_columns, comment_aggregates::all_columns,
is_creator_banned_from_community, is_creator_banned_from_community,
is_local_user_banned_from_community_selection,
creator_is_moderator, creator_is_moderator,
creator_is_admin, creator_is_admin,
subscribed_type_selection, subscribed_type_selection,

View File

@ -108,6 +108,7 @@ pub struct PersonMentionView {
pub recipient: Person, pub recipient: Person,
pub counts: CommentAggregates, pub counts: CommentAggregates,
pub creator_banned_from_community: bool, pub creator_banned_from_community: bool,
pub banned_from_community: bool,
pub creator_is_moderator: bool, pub creator_is_moderator: bool,
pub creator_is_admin: bool, pub creator_is_admin: bool,
pub subscribed: SubscribedType, pub subscribed: SubscribedType,
@ -131,6 +132,7 @@ pub struct CommentReplyView {
pub recipient: Person, pub recipient: Person,
pub counts: CommentAggregates, pub counts: CommentAggregates,
pub creator_banned_from_community: bool, pub creator_banned_from_community: bool,
pub banned_from_community: bool,
pub creator_is_moderator: bool, pub creator_is_moderator: bool,
pub creator_is_admin: bool, pub creator_is_admin: bool,
pub subscribed: SubscribedType, pub subscribed: SubscribedType,

View File

@ -29,6 +29,7 @@ use lemmy_api::{
get_captcha::get_captcha, get_captcha::get_captcha,
list_banned::list_banned_users, list_banned::list_banned_users,
list_logins::list_logins, list_logins::list_logins,
list_media::list_media,
login::login, login::login,
logout::logout, logout::logout,
notifications::{ notifications::{
@ -71,6 +72,7 @@ use lemmy_api::{
block::block_instance, block::block_instance,
federated_instances::get_federated_instances, federated_instances::get_federated_instances,
leave_admin::leave_admin, leave_admin::leave_admin,
list_all_media::list_all_media,
mod_log::get_mod_log, mod_log::get_mod_log,
purge::{ purge::{
comment::purge_comment, comment::purge_comment,
@ -282,6 +284,12 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) {
.wrap(rate_limit.import_user_settings()) .wrap(rate_limit.import_user_settings())
.route(web::post().to(import_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 // User actions
.service( .service(
web::scope("/user") web::scope("/user")
@ -339,6 +347,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) {
"/registration_application/approve", "/registration_application/approve",
web::put().to(approve_registration_application), web::put().to(approve_registration_application),
) )
.route("/list_all_media", web::get().to(list_all_media))
.service( .service(
web::scope("/purge") web::scope("/purge")
.route("/person", web::post().to(purge_person)) .route("/person", web::post().to(purge_person))