mirror of
https://github.com/LemmyNet/lemmy.git
synced 2025-01-15 14:35:54 +00:00
Image api rework (#5260)
* Split image endpoints into API v3 and v4 * Move into subfolders * Upload avatar endpoint and other changes * Various other changes fixes #1772 fixes #4001 * clippy * config options * fix ts bindings * fix api tests * Add option to disable image upload (fixes #1118) * split files into upload, download * move sitemap to top level, not in api * simplify code * add upload user banner * community icon/banner * site icon/banner * update js client * wip * add delete endpoints * change comment * optimization Co-authored-by: dullbananas <dull.bananas0@gmail.com> * move fn * 1024px banner * dont use static client * fix api tests * shear * proxy pictrs in request.rs (fixes #5270) * clippy * try to fix api tests * skip api tests * create user * debug * dbg * test * image * run another * fixed? * clippy * fix * fix health check --------- Co-authored-by: dullbananas <dull.bananas0@gmail.com>
This commit is contained in:
parent
c08e216ae8
commit
a91a03a536
36 changed files with 1187 additions and 889 deletions
|
@ -28,7 +28,7 @@
|
|||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"jest": "^29.5.0",
|
||||
"lemmy-js-client": "0.20.0-reports-combined.3",
|
||||
"lemmy-js-client": "0.20.0-image-api-rework.8",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.5.4",
|
||||
|
|
|
@ -30,8 +30,8 @@ importers:
|
|||
specifier: ^29.5.0
|
||||
version: 29.7.0(@types/node@22.9.0)
|
||||
lemmy-js-client:
|
||||
specifier: 0.20.0-reports-combined.3
|
||||
version: 0.20.0-reports-combined.3
|
||||
specifier: 0.20.0-image-api-rework.8
|
||||
version: 0.20.0-image-api-rework.8
|
||||
prettier:
|
||||
specifier: ^3.2.5
|
||||
version: 3.3.3
|
||||
|
@ -1167,8 +1167,8 @@ packages:
|
|||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
lemmy-js-client@0.20.0-reports-combined.3:
|
||||
resolution: {integrity: sha512-0Z/9S41r6NM8f09Gkxerq9zYBE6UcywXfeWNxsYknkyh0ZnKbtNxjTkSxE6JpRbz7wokKFRSH9NpwgNloQY5uw==}
|
||||
lemmy-js-client@0.20.0-image-api-rework.8:
|
||||
resolution: {integrity: sha512-Ns/ayfCSm2lHbdAU1tGIZSx6kJ2ZeS7UiXlPuH0IzHQSi8Yuyzj3srDCyHpE6Td3pmXbQlt9N1ziPE4KeRJ3CA==}
|
||||
|
||||
leven@3.1.0:
|
||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||
|
@ -3077,7 +3077,7 @@ snapshots:
|
|||
|
||||
kleur@3.0.3: {}
|
||||
|
||||
lemmy-js-client@0.20.0-reports-combined.3: {}
|
||||
lemmy-js-client@0.20.0-image-api-rework.8: {}
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
||||
|
|
|
@ -2,9 +2,9 @@ jest.setTimeout(120000);
|
|||
|
||||
import {
|
||||
UploadImage,
|
||||
DeleteImage,
|
||||
PurgePerson,
|
||||
PurgePost,
|
||||
DeleteImageParams,
|
||||
} from "lemmy-js-client";
|
||||
import {
|
||||
alpha,
|
||||
|
@ -41,8 +41,8 @@ afterAll(async () => {
|
|||
});
|
||||
|
||||
test("Upload image and delete it", async () => {
|
||||
const healthz = await fetch(alphaUrl + "/pictrs/healthz");
|
||||
expect(healthz.status).toBe(200);
|
||||
const health = await alpha.imageHealth();
|
||||
expect(health.success).toBeTruthy();
|
||||
|
||||
// Before running this test, you need to delete all previous images in the DB
|
||||
await deleteAllImages(alpha);
|
||||
|
@ -53,13 +53,12 @@ test("Upload image and delete it", async () => {
|
|||
image: Buffer.from("test"),
|
||||
};
|
||||
const upload = await alphaImage.uploadImage(upload_form);
|
||||
expect(upload.files![0].file).toBeDefined();
|
||||
expect(upload.files![0].delete_token).toBeDefined();
|
||||
expect(upload.url).toBeDefined();
|
||||
expect(upload.delete_url).toBeDefined();
|
||||
expect(upload.image_url).toBeDefined();
|
||||
expect(upload.filename).toBeDefined();
|
||||
expect(upload.delete_token).toBeDefined();
|
||||
|
||||
// ensure that image download is working. theres probably a better way to do this
|
||||
const response = await fetch(upload.url ?? "");
|
||||
const response = await fetch(upload.image_url ?? "");
|
||||
const content = await response.text();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
|
@ -76,26 +75,21 @@ test("Upload image and delete it", async () => {
|
|||
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.local_image.pictrs_delete_token}/${firstImage.local_image.pictrs_alias}`;
|
||||
expect(deleteUrl).toBe(upload.delete_url);
|
||||
|
||||
// Make sure the uploader is correct
|
||||
expect(firstImage.person.actor_id).toBe(
|
||||
expect(listMediaRes.images[0].person.actor_id).toBe(
|
||||
`http://lemmy-alpha:8541/u/lemmy_alpha`,
|
||||
);
|
||||
|
||||
// delete image
|
||||
const delete_form: DeleteImage = {
|
||||
token: upload.files![0].delete_token,
|
||||
filename: upload.files![0].file,
|
||||
const delete_form: DeleteImageParams = {
|
||||
token: upload.delete_token,
|
||||
filename: upload.filename,
|
||||
};
|
||||
const delete_ = await alphaImage.deleteImage(delete_form);
|
||||
expect(delete_).toBe(true);
|
||||
expect(delete_.success).toBe(true);
|
||||
|
||||
// ensure that image is deleted
|
||||
const response2 = await fetch(upload.url ?? "");
|
||||
const response2 = await fetch(upload.image_url ?? "");
|
||||
const content2 = await response2.text();
|
||||
expect(content2).toBe("");
|
||||
|
||||
|
@ -118,13 +112,12 @@ test("Purge user, uploaded image removed", async () => {
|
|||
image: Buffer.from("test"),
|
||||
};
|
||||
const upload = await user.uploadImage(upload_form);
|
||||
expect(upload.files![0].file).toBeDefined();
|
||||
expect(upload.files![0].delete_token).toBeDefined();
|
||||
expect(upload.url).toBeDefined();
|
||||
expect(upload.delete_url).toBeDefined();
|
||||
expect(upload.filename).toBeDefined();
|
||||
expect(upload.delete_token).toBeDefined();
|
||||
expect(upload.image_url).toBeDefined();
|
||||
|
||||
// ensure that image download is working. theres probably a better way to do this
|
||||
const response = await fetch(upload.url ?? "");
|
||||
const response = await fetch(upload.image_url ?? "");
|
||||
const content = await response.text();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
|
@ -137,7 +130,7 @@ test("Purge user, uploaded image removed", async () => {
|
|||
expect(delete_.success).toBe(true);
|
||||
|
||||
// ensure that image is deleted
|
||||
const response2 = await fetch(upload.url ?? "");
|
||||
const response2 = await fetch(upload.image_url ?? "");
|
||||
const content2 = await response2.text();
|
||||
expect(content2).toBe("");
|
||||
});
|
||||
|
@ -150,13 +143,12 @@ test("Purge post, linked image removed", async () => {
|
|||
image: Buffer.from("test"),
|
||||
};
|
||||
const upload = await user.uploadImage(upload_form);
|
||||
expect(upload.files![0].file).toBeDefined();
|
||||
expect(upload.files![0].delete_token).toBeDefined();
|
||||
expect(upload.url).toBeDefined();
|
||||
expect(upload.delete_url).toBeDefined();
|
||||
expect(upload.filename).toBeDefined();
|
||||
expect(upload.delete_token).toBeDefined();
|
||||
expect(upload.image_url).toBeDefined();
|
||||
|
||||
// ensure that image download is working. theres probably a better way to do this
|
||||
const response = await fetch(upload.url ?? "");
|
||||
const response = await fetch(upload.image_url ?? "");
|
||||
const content = await response.text();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
|
@ -164,9 +156,9 @@ test("Purge post, linked image removed", async () => {
|
|||
let post = await createPost(
|
||||
user,
|
||||
community.community!.community.id,
|
||||
upload.url,
|
||||
upload.image_url,
|
||||
);
|
||||
expect(post.post_view.post.url).toBe(upload.url);
|
||||
expect(post.post_view.post.url).toBe(upload.image_url);
|
||||
expect(post.post_view.image_details).toBeDefined();
|
||||
|
||||
// purge post
|
||||
|
@ -177,7 +169,7 @@ test("Purge post, linked image removed", async () => {
|
|||
expect(delete_.success).toBe(true);
|
||||
|
||||
// ensure that image is deleted
|
||||
const response2 = await fetch(upload.url ?? "");
|
||||
const response2 = await fetch(upload.image_url ?? "");
|
||||
const content2 = await response2.text();
|
||||
expect(content2).toBe("");
|
||||
});
|
||||
|
@ -199,11 +191,11 @@ test("Images in remote image post are proxied if setting enabled", async () => {
|
|||
// remote image gets proxied after upload
|
||||
expect(
|
||||
post.thumbnail_url?.startsWith(
|
||||
"http://lemmy-gamma:8561/api/v4/image_proxy?url",
|
||||
"http://lemmy-gamma:8561/api/v4/image/proxy?url",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
post.body?.startsWith("![](http://lemmy-gamma:8561/api/v4/image_proxy?url"),
|
||||
post.body?.startsWith("![](http://lemmy-gamma:8561/api/v4/image/proxy?url"),
|
||||
).toBeTruthy();
|
||||
|
||||
// Make sure that it ends with jpg, to be sure its an image
|
||||
|
@ -222,12 +214,12 @@ test("Images in remote image post are proxied if setting enabled", async () => {
|
|||
|
||||
expect(
|
||||
epsilonPost.thumbnail_url?.startsWith(
|
||||
"http://lemmy-epsilon:8581/api/v4/image_proxy?url",
|
||||
"http://lemmy-epsilon:8581/api/v4/image/proxy?url",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
epsilonPost.body?.startsWith(
|
||||
"![](http://lemmy-epsilon:8581/api/v4/image_proxy?url",
|
||||
"![](http://lemmy-epsilon:8581/api/v4/image/proxy?url",
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
|
@ -249,7 +241,7 @@ test("Thumbnail of remote image link is proxied if setting enabled", async () =>
|
|||
// remote image gets proxied after upload
|
||||
expect(
|
||||
post.thumbnail_url?.startsWith(
|
||||
"http://lemmy-gamma:8561/api/v4/image_proxy?url",
|
||||
"http://lemmy-gamma:8561/api/v4/image/proxy?url",
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
|
@ -267,7 +259,7 @@ test("Thumbnail of remote image link is proxied if setting enabled", async () =>
|
|||
|
||||
expect(
|
||||
epsilonPost.thumbnail_url?.startsWith(
|
||||
"http://lemmy-epsilon:8581/api/v4/image_proxy?url",
|
||||
"http://lemmy-epsilon:8581/api/v4/image/proxy?url",
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
|
@ -291,14 +283,14 @@ test("No image proxying if setting is disabled", async () => {
|
|||
let post = await createPost(
|
||||
alpha,
|
||||
community.community_view.community.id,
|
||||
upload.url,
|
||||
upload.image_url,
|
||||
`![](${sampleImage})`,
|
||||
);
|
||||
expect(post.post_view.post).toBeDefined();
|
||||
|
||||
// remote image doesn't get proxied after upload
|
||||
expect(
|
||||
post.post_view.post.url?.startsWith("http://127.0.0.1:8551/pictrs/image/"),
|
||||
post.post_view.post.url?.startsWith("http://lemmy-beta:8551/api/v4/image/"),
|
||||
).toBeTruthy();
|
||||
expect(post.post_view.post.body).toBe(`![](${sampleImage})`);
|
||||
|
||||
|
@ -311,7 +303,7 @@ test("No image proxying if setting is disabled", async () => {
|
|||
|
||||
// remote image doesn't get proxied after federation
|
||||
expect(
|
||||
betaPost.post.url?.startsWith("http://127.0.0.1:8551/pictrs/image/"),
|
||||
betaPost.post.url?.startsWith("http://lemmy-beta:8551/api/v4/image/"),
|
||||
).toBeTruthy();
|
||||
expect(betaPost.post.body).toBe(`![](${sampleImage})`);
|
||||
// Make sure the alt text got federated
|
||||
|
@ -333,7 +325,7 @@ test("Make regular post, and give it a custom thumbnail", async () => {
|
|||
alphaImage,
|
||||
community.community_view.community.id,
|
||||
wikipediaUrl,
|
||||
upload1.url!,
|
||||
upload1.image_url!,
|
||||
);
|
||||
|
||||
// Wait for the metadata to get fetched, since this is backgrounded now
|
||||
|
@ -343,7 +335,7 @@ test("Make regular post, and give it a custom thumbnail", async () => {
|
|||
);
|
||||
expect(post.post_view.post.url).toBe(wikipediaUrl);
|
||||
// Make sure it uses custom thumbnail
|
||||
expect(post.post_view.post.thumbnail_url).toBe(upload1.url);
|
||||
expect(post.post_view.post.thumbnail_url).toBe(upload1.image_url);
|
||||
});
|
||||
|
||||
test("Create an image post, and make sure a custom thumbnail doesn't overwrite it", async () => {
|
||||
|
@ -362,14 +354,14 @@ test("Create an image post, and make sure a custom thumbnail doesn't overwrite i
|
|||
let post = await createPostWithThumbnail(
|
||||
alphaImage,
|
||||
community.community_view.community.id,
|
||||
upload1.url!,
|
||||
upload2.url!,
|
||||
upload1.image_url!,
|
||||
upload2.image_url!,
|
||||
);
|
||||
post = await waitUntil(
|
||||
() => getPost(alphaImage, post.post_view.post.id),
|
||||
p => p.post_view.post.thumbnail_url != undefined,
|
||||
);
|
||||
expect(post.post_view.post.url).toBe(upload1.url);
|
||||
expect(post.post_view.post.url).toBe(upload1.image_url);
|
||||
// Make sure the custom thumbnail is ignored
|
||||
expect(post.post_view.post.thumbnail_url == upload2.url).toBe(false);
|
||||
expect(post.post_view.post.thumbnail_url == upload2.image_url).toBe(false);
|
||||
});
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
CommunityId,
|
||||
CommunityVisibility,
|
||||
CreatePrivateMessageReport,
|
||||
DeleteImage,
|
||||
EditCommunity,
|
||||
GetCommunityPendingFollowsCountResponse,
|
||||
GetReplies,
|
||||
|
@ -18,6 +17,7 @@ import {
|
|||
ListReports,
|
||||
ListReportsResponse,
|
||||
MyUserInfo,
|
||||
DeleteImageParams,
|
||||
PersonId,
|
||||
PostView,
|
||||
PrivateMessageReportResponse,
|
||||
|
@ -714,8 +714,6 @@ export async function saveUserSettingsBio(
|
|||
export async function saveUserSettingsFederated(
|
||||
api: LemmyHttp,
|
||||
): Promise<SuccessResponse> {
|
||||
let avatar = sampleImage;
|
||||
let banner = sampleImage;
|
||||
let bio = "a changed bio";
|
||||
let form: SaveUserSettings = {
|
||||
show_nsfw: false,
|
||||
|
@ -723,8 +721,6 @@ export async function saveUserSettingsFederated(
|
|||
default_post_sort_type: "Hot",
|
||||
default_listing_type: "All",
|
||||
interface_language: "",
|
||||
avatar,
|
||||
banner,
|
||||
display_name: "user321",
|
||||
show_avatars: false,
|
||||
send_notifications_to_email: false,
|
||||
|
@ -939,7 +935,7 @@ export async function deleteAllImages(api: LemmyHttp) {
|
|||
Promise.all(
|
||||
imagesRes.images
|
||||
.map(image => {
|
||||
const form: DeleteImage = {
|
||||
const form: DeleteImageParams = {
|
||||
token: image.local_image.pictrs_delete_token,
|
||||
filename: image.local_image.pictrs_alias,
|
||||
};
|
||||
|
|
|
@ -21,7 +21,6 @@ import {
|
|||
fetchFunction,
|
||||
alphaImage,
|
||||
unfollows,
|
||||
saveUserSettingsBio,
|
||||
getMyUser,
|
||||
getPersonDetails,
|
||||
} from "./shared";
|
||||
|
@ -192,43 +191,36 @@ test("Set a new avatar, old avatar is deleted", async () => {
|
|||
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);
|
||||
await alpha.uploadUserAvatar(upload_form1);
|
||||
const listMediaRes1 = await alphaImage.listMedia();
|
||||
expect(listMediaRes1.images.length).toBe(1);
|
||||
|
||||
let my_user1 = await alpha.getMyUser();
|
||||
expect(my_user1.local_user_view.person.avatar).toBeDefined();
|
||||
|
||||
const upload_form2: UploadImage = {
|
||||
image: Buffer.from("test2"),
|
||||
};
|
||||
const upload2 = await alphaImage.uploadImage(upload_form2);
|
||||
expect(upload2.url).toBeDefined();
|
||||
|
||||
let form2 = {
|
||||
avatar: upload2.url,
|
||||
};
|
||||
await saveUserSettings(alpha, form2);
|
||||
await alpha.uploadUserAvatar(upload_form2);
|
||||
// make sure only the new avatar is kept
|
||||
const listMediaRes2 = await alphaImage.listMedia();
|
||||
expect(listMediaRes2.images.length).toBe(1);
|
||||
|
||||
// Upload that same form2 avatar, make sure it isn't replaced / deleted
|
||||
await saveUserSettings(alpha, form2);
|
||||
await alpha.uploadUserAvatar(upload_form2);
|
||||
// make sure only the new avatar is kept
|
||||
const listMediaRes3 = await alphaImage.listMedia();
|
||||
expect(listMediaRes3.images.length).toBe(1);
|
||||
|
||||
// Now try to save a user settings, with the icon missing,
|
||||
// and make sure it doesn't clear the data, or delete the image
|
||||
await saveUserSettingsBio(alpha);
|
||||
let my_user = await getMyUser(alpha);
|
||||
expect(my_user.local_user_view.person.avatar).toBe(upload2.url);
|
||||
|
||||
// make sure only the new avatar is kept
|
||||
const listMediaRes4 = await alphaImage.listMedia();
|
||||
expect(listMediaRes4.images.length).toBe(1);
|
||||
|
||||
// delete the avatar
|
||||
await alpha.deleteUserAvatar();
|
||||
// make sure only the new avatar is kept
|
||||
const listMediaRes5 = await alphaImage.listMedia();
|
||||
expect(listMediaRes5.images.length).toBe(0);
|
||||
let my_user2 = await alpha.getMyUser();
|
||||
expect(my_user2.local_user_view.person.avatar).toBeUndefined();
|
||||
});
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
# or
|
||||
|
||||
# If enabled, all images from remote domains are rewritten to pass through
|
||||
# `/api/v4/image_proxy`, including embedded images in markdown. Images are stored temporarily
|
||||
# `/api/v4/image/proxy`, including embedded images in markdown. Images are stored temporarily
|
||||
# in pict-rs for caching. This improves privacy as users don't expose their IP to untrusted
|
||||
# servers, and decreases load on other servers. However it increases bandwidth use for the
|
||||
# local server.
|
||||
|
@ -59,6 +59,14 @@
|
|||
upload_timeout: 30
|
||||
# Resize post thumbnails to this maximum width/height.
|
||||
max_thumbnail_size: 512
|
||||
# Maximum size for user avatar, community icon and site icon.
|
||||
max_avatar_size: 512
|
||||
# Maximum size for user, community and site banner. Larger images are downscaled to fit
|
||||
# into a square of this size.
|
||||
max_banner_size: 1024
|
||||
# Prevent users from uploading images for posts or embedding in markdown. Avatars, icons and
|
||||
# banners can still be uploaded.
|
||||
image_upload_disabled: false
|
||||
}
|
||||
# Email sending configuration. All options except login/password are mandatory
|
||||
email: {
|
||||
|
|
|
@ -3,12 +3,10 @@ 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,
|
||||
process_markdown_opt,
|
||||
proxy_image_link_opt_api,
|
||||
send_verification_email,
|
||||
},
|
||||
SuccessResponse,
|
||||
|
@ -21,7 +19,7 @@ use lemmy_db_schema::{
|
|||
person::{Person, PersonUpdateForm},
|
||||
},
|
||||
traits::Crud,
|
||||
utils::{diesel_string_update, diesel_url_update},
|
||||
utils::diesel_string_update,
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||
use lemmy_utils::{
|
||||
|
@ -46,14 +44,6 @@ pub async fn save_user_settings(
|
|||
.as_deref(),
|
||||
);
|
||||
|
||||
let avatar = diesel_url_update(data.avatar.as_deref())?;
|
||||
replace_image(&avatar, &local_user_view.person.avatar, &context).await?;
|
||||
let avatar = proxy_image_link_opt_api(avatar, &context).await?;
|
||||
|
||||
let banner = diesel_url_update(data.banner.as_deref())?;
|
||||
replace_image(&banner, &local_user_view.person.banner, &context).await?;
|
||||
let banner = proxy_image_link_opt_api(banner, &context).await?;
|
||||
|
||||
let display_name = diesel_string_update(data.display_name.as_deref());
|
||||
let matrix_user_id = diesel_string_update(data.matrix_user_id.as_deref());
|
||||
let email_deref = data.email.as_deref().map(str::to_lowercase);
|
||||
|
@ -108,8 +98,6 @@ pub async fn save_user_settings(
|
|||
bio,
|
||||
matrix_user_id,
|
||||
bot_account: data.bot_account,
|
||||
avatar,
|
||||
banner,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
|
@ -76,5 +76,6 @@ pub async fn leave_admin(
|
|||
blocked_urls,
|
||||
tagline,
|
||||
my_user: None,
|
||||
image_upload_disabled: context.settings().pictrs()?.image_upload_disabled,
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -78,29 +78,18 @@ mod tests {
|
|||
instance::Instance,
|
||||
local_user::{LocalUser, LocalUserInsertForm},
|
||||
person::{Person, PersonInsertForm},
|
||||
secret::Secret,
|
||||
},
|
||||
traits::Crud,
|
||||
utils::build_db_pool_for_tests,
|
||||
};
|
||||
use lemmy_utils::{error::LemmyResult, rate_limit::RateLimitCell};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
use pretty_assertions::assert_eq;
|
||||
use reqwest::Client;
|
||||
use reqwest_middleware::ClientBuilder;
|
||||
use serial_test::serial;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_should_not_validate_user_token_after_password_change() -> LemmyResult<()> {
|
||||
let pool_ = build_db_pool_for_tests();
|
||||
let pool = &mut (&pool_).into();
|
||||
let secret = Secret::init(pool).await?;
|
||||
let context = LemmyContext::create(
|
||||
pool_.clone(),
|
||||
ClientBuilder::new(Client::default()).build(),
|
||||
secret,
|
||||
RateLimitCell::with_test_config(),
|
||||
);
|
||||
let context = LemmyContext::init_test_context().await;
|
||||
let pool = &mut context.pool();
|
||||
|
||||
let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?;
|
||||
|
||||
|
|
|
@ -177,12 +177,6 @@ pub struct EditCommunity {
|
|||
/// A shorter, one line description of your community.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub description: Option<String>,
|
||||
/// An icon URL.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub icon: Option<String>,
|
||||
/// A banner URL.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub banner: Option<String>,
|
||||
/// Whether its an NSFW community.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub nsfw: Option<bool>,
|
||||
|
|
|
@ -15,6 +15,9 @@ use std::sync::Arc;
|
|||
pub struct LemmyContext {
|
||||
pool: ActualDbPool,
|
||||
client: Arc<ClientWithMiddleware>,
|
||||
/// Pictrs requests must bypass proxy. Unfortunately no_proxy can only be set on ClientBuilder
|
||||
/// and not on RequestBuilder, so we need a separate client here.
|
||||
pictrs_client: Arc<ClientWithMiddleware>,
|
||||
secret: Arc<Secret>,
|
||||
rate_limit_cell: RateLimitCell,
|
||||
}
|
||||
|
@ -23,12 +26,14 @@ impl LemmyContext {
|
|||
pub fn create(
|
||||
pool: ActualDbPool,
|
||||
client: ClientWithMiddleware,
|
||||
pictrs_client: ClientWithMiddleware,
|
||||
secret: Secret,
|
||||
rate_limit_cell: RateLimitCell,
|
||||
) -> LemmyContext {
|
||||
LemmyContext {
|
||||
pool,
|
||||
client: Arc::new(client),
|
||||
pictrs_client: Arc::new(pictrs_client),
|
||||
secret: Arc::new(secret),
|
||||
rate_limit_cell,
|
||||
}
|
||||
|
@ -42,6 +47,9 @@ impl LemmyContext {
|
|||
pub fn client(&self) -> &ClientWithMiddleware {
|
||||
&self.client
|
||||
}
|
||||
pub fn pictrs_client(&self) -> &ClientWithMiddleware {
|
||||
&self.pictrs_client
|
||||
}
|
||||
pub fn settings(&self) -> &'static Settings {
|
||||
&SETTINGS
|
||||
}
|
||||
|
@ -70,7 +78,13 @@ impl LemmyContext {
|
|||
|
||||
let rate_limit_cell = RateLimitCell::with_test_config();
|
||||
|
||||
let context = LemmyContext::create(pool, client, secret, rate_limit_cell.clone());
|
||||
let context = LemmyContext::create(
|
||||
pool,
|
||||
client.clone(),
|
||||
client,
|
||||
secret,
|
||||
rate_limit_cell.clone(),
|
||||
);
|
||||
|
||||
FederationConfig::builder()
|
||||
.domain(context.settings().hostname.clone())
|
||||
|
|
54
crates/api_common/src/image.rs
Normal file
54
crates/api_common/src/image.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
use lemmy_db_schema::newtypes::CommunityId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
#[cfg(feature = "full")]
|
||||
use ts_rs::TS;
|
||||
use url::Url;
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "full", derive(TS))]
|
||||
#[cfg_attr(feature = "full", ts(export))]
|
||||
pub struct ImageGetParams {
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub file_type: Option<String>,
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub max_size: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "full", derive(TS))]
|
||||
#[cfg_attr(feature = "full", ts(export))]
|
||||
pub struct DeleteImageParams {
|
||||
pub filename: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "full", derive(TS))]
|
||||
#[cfg_attr(feature = "full", ts(export))]
|
||||
pub struct ImageProxyParams {
|
||||
pub url: String,
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub file_type: Option<String>,
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub max_size: Option<i32>,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "full", derive(TS))]
|
||||
#[cfg_attr(feature = "full", ts(export))]
|
||||
pub struct UploadImageResponse {
|
||||
pub image_url: Url,
|
||||
pub filename: String,
|
||||
pub delete_token: String,
|
||||
}
|
||||
|
||||
/// Parameter for setting community icon or banner. Can't use POST data here as it already contains
|
||||
/// the image data.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub struct CommunityIdQuery {
|
||||
pub id: CommunityId,
|
||||
}
|
|
@ -7,6 +7,7 @@ pub mod community;
|
|||
#[cfg(feature = "full")]
|
||||
pub mod context;
|
||||
pub mod custom_emoji;
|
||||
pub mod image;
|
||||
pub mod oauth_provider;
|
||||
pub mod person;
|
||||
pub mod post;
|
||||
|
|
|
@ -120,12 +120,6 @@ pub struct SaveUserSettings {
|
|||
/// The language of the lemmy interface
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub interface_language: Option<String>,
|
||||
/// A URL for your avatar.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub avatar: Option<String>,
|
||||
/// A URL for your banner.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub banner: Option<String>,
|
||||
/// Your display name, which can contain strange characters, and does not need to be unique.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub display_name: Option<String>,
|
||||
|
|
|
@ -9,13 +9,10 @@ use activitypub_federation::config::Data;
|
|||
use chrono::{DateTime, Utc};
|
||||
use encoding_rs::{Encoding, UTF_8};
|
||||
use futures::StreamExt;
|
||||
use lemmy_db_schema::{
|
||||
newtypes::DbUrl,
|
||||
source::{
|
||||
use lemmy_db_schema::source::{
|
||||
images::{ImageDetailsForm, LocalImage, LocalImageForm},
|
||||
post::{Post, PostUpdateForm},
|
||||
site::Site,
|
||||
},
|
||||
};
|
||||
use lemmy_utils::{
|
||||
error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult},
|
||||
|
@ -260,7 +257,8 @@ fn extract_opengraph_data(html_bytes: &[u8], url: &Url) -> LemmyResult<OpenGraph
|
|||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct PictrsResponse {
|
||||
pub files: Option<Vec<PictrsFile>>,
|
||||
#[serde(default)]
|
||||
pub files: Vec<PictrsFile>,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
|
@ -272,9 +270,15 @@ pub struct PictrsFile {
|
|||
}
|
||||
|
||||
impl PictrsFile {
|
||||
pub fn thumbnail_url(&self, protocol_and_hostname: &str) -> Result<Url, url::ParseError> {
|
||||
pub fn image_url(&self, protocol_and_hostname: &str) -> Result<Url, url::ParseError> {
|
||||
Url::parse(&format!(
|
||||
"{protocol_and_hostname}/pictrs/image/{}",
|
||||
"{protocol_and_hostname}/api/v4/image/{}",
|
||||
self.file
|
||||
))
|
||||
}
|
||||
pub fn delete_url(&self, protocol_and_hostname: &str) -> Result<Url, url::ParseError> {
|
||||
Url::parse(&format!(
|
||||
"{protocol_and_hostname}/api/v4/image/{}",
|
||||
self.file
|
||||
))
|
||||
}
|
||||
|
@ -315,7 +319,7 @@ struct PictrsPurgeResponse {
|
|||
/// - It might not be an image
|
||||
/// - Pictrs might not be set up
|
||||
pub async fn purge_image_from_pictrs(image_url: &Url, context: &LemmyContext) -> LemmyResult<()> {
|
||||
is_image_content_type(context.client(), image_url).await?;
|
||||
is_image_content_type(context.pictrs_client(), image_url).await?;
|
||||
|
||||
let alias = image_url
|
||||
.path_segments()
|
||||
|
@ -323,14 +327,19 @@ pub async fn purge_image_from_pictrs(image_url: &Url, context: &LemmyContext) ->
|
|||
.next_back()
|
||||
.ok_or(LemmyErrorType::ImageUrlMissingLastPathSegment)?;
|
||||
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
// Delete db row if any (old Lemmy versions didnt generate this).
|
||||
LocalImage::delete_by_alias(&mut context.pool(), alias)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let pictrs_config = context.settings().pictrs()?;
|
||||
let purge_url = format!("{}internal/purge?alias={}", pictrs_config.url, alias);
|
||||
|
||||
let pictrs_api_key = pictrs_config
|
||||
.api_key
|
||||
.ok_or(LemmyErrorType::PictrsApiKeyNotProvided)?;
|
||||
let response = context
|
||||
.client()
|
||||
.pictrs_client()
|
||||
.post(&purge_url)
|
||||
.timeout(REQWEST_TIMEOUT)
|
||||
.header("x-api-token", pictrs_api_key)
|
||||
|
@ -351,13 +360,18 @@ pub async fn delete_image_from_pictrs(
|
|||
delete_token: &str,
|
||||
context: &LemmyContext,
|
||||
) -> LemmyResult<()> {
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
// Delete db row if any (old Lemmy versions didnt generate this).
|
||||
LocalImage::delete_by_alias(&mut context.pool(), alias)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let pictrs_config = context.settings().pictrs()?;
|
||||
let url = format!(
|
||||
"{}image/delete/{}/{}",
|
||||
pictrs_config.url, &delete_token, &alias
|
||||
);
|
||||
context
|
||||
.client()
|
||||
.pictrs_client()
|
||||
.delete(&url)
|
||||
.timeout(REQWEST_TIMEOUT)
|
||||
.send()
|
||||
|
@ -369,7 +383,7 @@ pub async fn delete_image_from_pictrs(
|
|||
/// Retrieves the image with local pict-rs and generates a thumbnail. Returns the thumbnail url.
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> LemmyResult<Url> {
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
let pictrs_config = context.settings().pictrs()?;
|
||||
|
||||
match pictrs_config.image_mode {
|
||||
PictrsImageMode::None => return Ok(image_url.clone()),
|
||||
|
@ -380,16 +394,15 @@ async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> L
|
|||
};
|
||||
|
||||
// fetch remote non-pictrs images for persistent thumbnail link
|
||||
// TODO: should limit size once supported by pictrs
|
||||
let fetch_url = format!(
|
||||
"{}image/download?url={}&resize={}",
|
||||
pictrs_config.url,
|
||||
encode(image_url.as_str()),
|
||||
context.settings().pictrs_config()?.max_thumbnail_size
|
||||
context.settings().pictrs()?.max_thumbnail_size
|
||||
);
|
||||
|
||||
let res = context
|
||||
.client()
|
||||
.pictrs_client()
|
||||
.get(&fetch_url)
|
||||
.timeout(REQWEST_TIMEOUT)
|
||||
.send()
|
||||
|
@ -398,9 +411,8 @@ async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> L
|
|||
.json::<PictrsResponse>()
|
||||
.await?;
|
||||
|
||||
let files = res.files.unwrap_or_default();
|
||||
|
||||
let image = files
|
||||
let image = res
|
||||
.files
|
||||
.first()
|
||||
.ok_or(LemmyErrorType::PictrsResponseError(res.msg))?;
|
||||
|
||||
|
@ -412,7 +424,7 @@ async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> L
|
|||
pictrs_delete_token: image.delete_token.clone(),
|
||||
};
|
||||
let protocol_and_hostname = context.settings().get_protocol_and_hostname();
|
||||
let thumbnail_url = image.thumbnail_url(&protocol_and_hostname)?;
|
||||
let thumbnail_url = image.image_url(&protocol_and_hostname)?;
|
||||
|
||||
// Also store the details for the image
|
||||
let details_form = image.details.build_image_details_form(&thumbnail_url);
|
||||
|
@ -429,14 +441,14 @@ pub async fn fetch_pictrs_proxied_image_details(
|
|||
image_url: &Url,
|
||||
context: &LemmyContext,
|
||||
) -> LemmyResult<PictrsFileDetails> {
|
||||
let pictrs_url = context.settings().pictrs_config()?.url;
|
||||
let pictrs_url = context.settings().pictrs()?.url;
|
||||
let encoded_image_url = encode(image_url.as_str());
|
||||
|
||||
// Pictrs needs you to fetch the proxied image before you can fetch the details
|
||||
let proxy_url = format!("{pictrs_url}image/original?proxy={encoded_image_url}");
|
||||
|
||||
context
|
||||
.client()
|
||||
.pictrs_client()
|
||||
.get(&proxy_url)
|
||||
.timeout(REQWEST_TIMEOUT)
|
||||
.send()
|
||||
|
@ -447,7 +459,7 @@ pub async fn fetch_pictrs_proxied_image_details(
|
|||
let details_url = format!("{pictrs_url}image/details/original?proxy={encoded_image_url}");
|
||||
|
||||
let res = context
|
||||
.client()
|
||||
.pictrs_client()
|
||||
.get(&details_url)
|
||||
.timeout(REQWEST_TIMEOUT)
|
||||
.send()
|
||||
|
@ -476,29 +488,6 @@ async fn is_image_content_type(client: &ClientWithMiddleware, url: &Url) -> Lemm
|
|||
}
|
||||
}
|
||||
|
||||
/// When adding a new avatar, banner or similar image, delete the old one.
|
||||
pub async fn replace_image(
|
||||
new_image: &Option<Option<DbUrl>>,
|
||||
old_image: &Option<DbUrl>,
|
||||
context: &Data<LemmyContext>,
|
||||
) -> LemmyResult<()> {
|
||||
if let (Some(Some(new_image)), Some(old_image)) = (new_image, old_image) {
|
||||
// Note: Oftentimes front ends will include the current image in the form.
|
||||
// In this case, deleting `old_image` would also be deletion of `new_image`,
|
||||
// so the deletion must be skipped for the image to be kept.
|
||||
if new_image != old_image {
|
||||
// Ignore errors because image may be stored externally.
|
||||
let image = LocalImage::delete_by_url(&mut context.pool(), old_image)
|
||||
.await
|
||||
.ok();
|
||||
if let Some(image) = image {
|
||||
delete_image_from_pictrs(&image.pictrs_alias, &image.pictrs_delete_token, context).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
|
|
|
@ -199,9 +199,6 @@ pub struct CreateSite {
|
|||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub description: Option<String>,
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub icon: Option<String>,
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub banner: Option<String>,
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub community_creation_admin_only: Option<bool>,
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
|
@ -292,12 +289,6 @@ pub struct EditSite {
|
|||
/// A shorter, one line description of your site.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub description: Option<String>,
|
||||
/// A url for your site's icon.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub icon: Option<String>,
|
||||
/// A url for your site's banner.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub banner: Option<String>,
|
||||
/// Limits community creation to admins only.
|
||||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub community_creation_admin_only: Option<bool>,
|
||||
|
@ -443,6 +434,9 @@ pub struct GetSiteResponse {
|
|||
#[cfg_attr(feature = "full", ts(optional))]
|
||||
pub admin_oauth_providers: Option<Vec<OAuthProvider>>,
|
||||
pub blocked_urls: Vec<LocalSiteUrlBlocklist>,
|
||||
// If true then uploads for post images or markdown images are disabled. Only avatars, icons and
|
||||
// banners can be set.
|
||||
pub image_upload_disabled: bool,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
|
|
|
@ -1060,7 +1060,7 @@ pub async fn process_markdown(
|
|||
|
||||
markdown_check_for_blocked_urls(&text, url_blocklist)?;
|
||||
|
||||
if context.settings().pictrs_config()?.image_mode == PictrsImageMode::ProxyAllImages {
|
||||
if context.settings().pictrs()?.image_mode == PictrsImageMode::ProxyAllImages {
|
||||
let (text, links) = markdown_rewrite_image_links(text);
|
||||
RemoteImage::create(&mut context.pool(), links.clone()).await?;
|
||||
|
||||
|
@ -1128,37 +1128,7 @@ async fn proxy_image_link_internal(
|
|||
/// Rewrite a link to go through `/api/v4/image_proxy` endpoint. This is only for remote urls and
|
||||
/// if image_proxy setting is enabled.
|
||||
pub async fn proxy_image_link(link: Url, context: &LemmyContext) -> LemmyResult<DbUrl> {
|
||||
proxy_image_link_internal(
|
||||
link,
|
||||
context.settings().pictrs_config()?.image_mode,
|
||||
context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn proxy_image_link_opt_api(
|
||||
link: Option<Option<DbUrl>>,
|
||||
context: &LemmyContext,
|
||||
) -> LemmyResult<Option<Option<DbUrl>>> {
|
||||
if let Some(Some(link)) = link {
|
||||
proxy_image_link(link.into(), context)
|
||||
.await
|
||||
.map(Some)
|
||||
.map(Some)
|
||||
} else {
|
||||
Ok(link)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn proxy_image_link_api(
|
||||
link: Option<DbUrl>,
|
||||
context: &LemmyContext,
|
||||
) -> LemmyResult<Option<DbUrl>> {
|
||||
if let Some(link) = link {
|
||||
proxy_image_link(link.into(), context).await.map(Some)
|
||||
} else {
|
||||
Ok(link)
|
||||
}
|
||||
proxy_image_link_internal(link, context.settings().pictrs()?.image_mode, context).await
|
||||
}
|
||||
|
||||
pub async fn proxy_image_link_opt_apub(
|
||||
|
@ -1177,7 +1147,7 @@ fn build_proxied_image_url(
|
|||
protocol_and_hostname: &str,
|
||||
) -> Result<Url, url::ParseError> {
|
||||
Url::parse(&format!(
|
||||
"{}/api/v4/image_proxy?url={}",
|
||||
"{}/api/v4/image/proxy?url={}",
|
||||
protocol_and_hostname,
|
||||
encode(link.as_str())
|
||||
))
|
||||
|
@ -1256,7 +1226,7 @@ mod tests {
|
|||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
"https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Flemmy-beta%2Fimage.png",
|
||||
"https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Flemmy-beta%2Fimage.png",
|
||||
proxied.as_str()
|
||||
);
|
||||
|
||||
|
|
|
@ -13,7 +13,6 @@ use lemmy_api_common::{
|
|||
is_admin,
|
||||
local_site_to_slur_regex,
|
||||
process_markdown_opt,
|
||||
proxy_image_link_api,
|
||||
EndpointType,
|
||||
},
|
||||
};
|
||||
|
@ -31,7 +30,6 @@ use lemmy_db_schema::{
|
|||
},
|
||||
},
|
||||
traits::{ApubActor, Crud, Followable, Joinable},
|
||||
utils::diesel_url_create,
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||
use lemmy_utils::{
|
||||
|
@ -76,12 +74,6 @@ pub async fn create_community(
|
|||
check_slurs(desc, &slur_regex)?;
|
||||
}
|
||||
|
||||
let icon = diesel_url_create(data.icon.as_deref())?;
|
||||
let icon = proxy_image_link_api(icon, &context).await?;
|
||||
|
||||
let banner = diesel_url_create(data.banner.as_deref())?;
|
||||
let banner = proxy_image_link_api(banner, &context).await?;
|
||||
|
||||
is_valid_actor_name(&data.name, local_site.actor_name_max_length as usize)?;
|
||||
|
||||
if let Some(desc) = &data.description {
|
||||
|
@ -108,8 +100,6 @@ pub async fn create_community(
|
|||
let community_form = CommunityInsertForm {
|
||||
sidebar,
|
||||
description,
|
||||
icon,
|
||||
banner,
|
||||
nsfw: data.nsfw,
|
||||
actor_id: Some(community_actor_id.clone()),
|
||||
private_key: Some(keypair.private_key),
|
||||
|
|
|
@ -6,14 +6,12 @@ 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,
|
||||
get_url_blocklist,
|
||||
local_site_to_slur_regex,
|
||||
process_markdown_opt,
|
||||
proxy_image_link_opt_api,
|
||||
},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
|
@ -23,7 +21,7 @@ use lemmy_db_schema::{
|
|||
local_site::LocalSite,
|
||||
},
|
||||
traits::Crud,
|
||||
utils::{diesel_string_update, diesel_url_update},
|
||||
utils::diesel_string_update,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::{
|
||||
|
@ -58,14 +56,6 @@ pub async fn update_community(
|
|||
|
||||
let old_community = Community::read(&mut context.pool(), data.community_id).await?;
|
||||
|
||||
let icon = diesel_url_update(data.icon.as_deref())?;
|
||||
replace_image(&icon, &old_community.icon, &context).await?;
|
||||
let icon = proxy_image_link_opt_api(icon, &context).await?;
|
||||
|
||||
let banner = diesel_url_update(data.banner.as_deref())?;
|
||||
replace_image(&banner, &old_community.banner, &context).await?;
|
||||
let banner = proxy_image_link_opt_api(banner, &context).await?;
|
||||
|
||||
// Verify its a mod (only mods can edit it)
|
||||
check_community_mod_action(
|
||||
&local_user_view.person,
|
||||
|
@ -91,8 +81,6 @@ pub async fn update_community(
|
|||
title: data.title.clone(),
|
||||
sidebar,
|
||||
description,
|
||||
icon,
|
||||
banner,
|
||||
nsfw: data.nsfw,
|
||||
posting_restricted_to_mods: data.posting_restricted_to_mods,
|
||||
visibility: data.visibility,
|
||||
|
|
|
@ -13,7 +13,6 @@ use lemmy_api_common::{
|
|||
local_site_rate_limit_to_rate_limit_config,
|
||||
local_site_to_slur_regex,
|
||||
process_markdown_opt,
|
||||
proxy_image_link_api,
|
||||
},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
|
@ -24,7 +23,7 @@ use lemmy_db_schema::{
|
|||
site::{Site, SiteUpdateForm},
|
||||
},
|
||||
traits::Crud,
|
||||
utils::{diesel_string_update, diesel_url_create},
|
||||
utils::diesel_string_update,
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||
use lemmy_utils::{
|
||||
|
@ -63,18 +62,10 @@ pub async fn create_site(
|
|||
let url_blocklist = get_url_blocklist(&context).await?;
|
||||
let sidebar = process_markdown_opt(&data.sidebar, &slur_regex, &url_blocklist, &context).await?;
|
||||
|
||||
let icon = diesel_url_create(data.icon.as_deref())?;
|
||||
let icon = proxy_image_link_api(icon, &context).await?;
|
||||
|
||||
let banner = diesel_url_create(data.banner.as_deref())?;
|
||||
let banner = proxy_image_link_api(banner, &context).await?;
|
||||
|
||||
let site_form = SiteUpdateForm {
|
||||
name: Some(data.name.clone()),
|
||||
sidebar: diesel_string_update(sidebar.as_deref()),
|
||||
description: diesel_string_update(data.description.as_deref()),
|
||||
icon: Some(icon),
|
||||
banner: Some(banner),
|
||||
actor_id: Some(actor_id),
|
||||
last_refreshed_at: Some(Utc::now()),
|
||||
inbox_url,
|
||||
|
|
|
@ -69,5 +69,6 @@ async fn read_site(context: &LemmyContext) -> LemmyResult<GetSiteResponse> {
|
|||
tagline,
|
||||
oauth_providers: Some(oauth_providers),
|
||||
admin_oauth_providers: Some(admin_oauth_providers),
|
||||
image_upload_disabled: context.settings().pictrs()?.image_upload_disabled,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ use actix_web::web::Json;
|
|||
use chrono::Utc;
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
request::replace_image,
|
||||
site::{EditSite, SiteResponse},
|
||||
utils::{
|
||||
get_url_blocklist,
|
||||
|
@ -13,7 +12,6 @@ use lemmy_api_common::{
|
|||
local_site_rate_limit_to_rate_limit_config,
|
||||
local_site_to_slur_regex,
|
||||
process_markdown_opt,
|
||||
proxy_image_link_opt_api,
|
||||
},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
|
@ -26,7 +24,7 @@ use lemmy_db_schema::{
|
|||
site::{Site, SiteUpdateForm},
|
||||
},
|
||||
traits::Crud,
|
||||
utils::{diesel_string_update, diesel_url_update},
|
||||
utils::diesel_string_update,
|
||||
RegistrationMode,
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||
|
@ -72,20 +70,10 @@ pub async fn update_site(
|
|||
.as_deref(),
|
||||
);
|
||||
|
||||
let icon = diesel_url_update(data.icon.as_deref())?;
|
||||
replace_image(&icon, &site.icon, &context).await?;
|
||||
let icon = proxy_image_link_opt_api(icon, &context).await?;
|
||||
|
||||
let banner = diesel_url_update(data.banner.as_deref())?;
|
||||
replace_image(&banner, &site.banner, &context).await?;
|
||||
let banner = proxy_image_link_opt_api(banner, &context).await?;
|
||||
|
||||
let site_form = SiteUpdateForm {
|
||||
name: data.name.clone(),
|
||||
sidebar,
|
||||
description: diesel_string_update(data.description.as_deref()),
|
||||
icon,
|
||||
banner,
|
||||
content_warning: diesel_string_update(data.content_warning.as_deref()),
|
||||
updated: Some(Some(Utc::now())),
|
||||
..Default::default()
|
||||
|
|
|
@ -1,353 +0,0 @@
|
|||
use actix_web::{
|
||||
body::{BodyStream, BoxBody},
|
||||
http::{
|
||||
header::{HeaderName, ACCEPT_ENCODING, HOST},
|
||||
Method,
|
||||
StatusCode,
|
||||
},
|
||||
web::*,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
Responder,
|
||||
};
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use http::HeaderValue;
|
||||
use lemmy_api_common::{context::LemmyContext, request::PictrsResponse};
|
||||
use lemmy_db_schema::source::{
|
||||
images::{LocalImage, LocalImageForm, RemoteImage},
|
||||
local_site::LocalSite,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::{error::LemmyResult, rate_limit::RateLimitCell, REQWEST_TIMEOUT};
|
||||
use reqwest::Body;
|
||||
use reqwest_middleware::{ClientWithMiddleware, RequestBuilder};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
pub fn config(cfg: &mut ServiceConfig, client: ClientWithMiddleware, rate_limit: &RateLimitCell) {
|
||||
cfg
|
||||
.app_data(Data::new(client))
|
||||
.service(
|
||||
resource("/pictrs/image")
|
||||
.wrap(rate_limit.image())
|
||||
.route(post().to(upload)),
|
||||
)
|
||||
// This has optional query params: /image/{filename}?format=jpg&thumbnail=256
|
||||
.service(resource("/pictrs/image/{filename}").route(get().to(full_res)))
|
||||
.service(resource("/pictrs/image/delete/{token}/{filename}").route(get().to(delete)))
|
||||
.service(resource("/pictrs/healthz").route(get().to(healthz)));
|
||||
}
|
||||
|
||||
trait ProcessUrl {
|
||||
/// If thumbnail or format is given, this uses the pictrs process endpoint.
|
||||
/// Otherwise, it uses the normal pictrs url (IE image/original).
|
||||
fn process_url(&self, image_url: &str, pictrs_url: &Url) -> String;
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct PictrsGetParams {
|
||||
format: Option<String>,
|
||||
thumbnail: Option<i32>,
|
||||
}
|
||||
|
||||
impl ProcessUrl for PictrsGetParams {
|
||||
fn process_url(&self, src: &str, pictrs_url: &Url) -> String {
|
||||
if self.format.is_none() && self.thumbnail.is_none() {
|
||||
format!("{}image/original/{}", pictrs_url, src)
|
||||
} else {
|
||||
// Take file type from name, or jpg if nothing is given
|
||||
let format = self
|
||||
.clone()
|
||||
.format
|
||||
.unwrap_or_else(|| src.split('.').last().unwrap_or("jpg").to_string());
|
||||
|
||||
let mut url = format!("{}image/process.{}?src={}", pictrs_url, format, src);
|
||||
|
||||
if let Some(size) = self.thumbnail {
|
||||
url = format!("{url}&thumbnail={size}",);
|
||||
}
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct ImageProxyParams {
|
||||
url: String,
|
||||
format: Option<String>,
|
||||
thumbnail: Option<i32>,
|
||||
}
|
||||
|
||||
impl ProcessUrl for ImageProxyParams {
|
||||
fn process_url(&self, proxy_url: &str, pictrs_url: &Url) -> String {
|
||||
if self.format.is_none() && self.thumbnail.is_none() {
|
||||
format!("{}image/original?proxy={}", pictrs_url, proxy_url)
|
||||
} else {
|
||||
// Take file type from name, or jpg if nothing is given
|
||||
let format = self
|
||||
.clone()
|
||||
.format
|
||||
.unwrap_or_else(|| proxy_url.split('.').last().unwrap_or("jpg").to_string());
|
||||
|
||||
let mut url = format!("{}image/process.{}?proxy={}", pictrs_url, format, proxy_url);
|
||||
|
||||
if let Some(size) = self.thumbnail {
|
||||
url = format!("{url}&thumbnail={size}",);
|
||||
}
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
fn adapt_request(
|
||||
request: &HttpRequest,
|
||||
client: &ClientWithMiddleware,
|
||||
url: String,
|
||||
) -> RequestBuilder {
|
||||
// remove accept-encoding header so that pictrs doesn't compress the response
|
||||
const INVALID_HEADERS: &[HeaderName] = &[ACCEPT_ENCODING, HOST];
|
||||
|
||||
let client_request = client
|
||||
.request(convert_method(request.method()), url)
|
||||
.timeout(REQWEST_TIMEOUT);
|
||||
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.fold(client_request, |client_req, (key, value)| {
|
||||
if INVALID_HEADERS.contains(key) {
|
||||
client_req
|
||||
} else {
|
||||
// TODO: remove as_str and as_bytes conversions after actix-web upgrades to http 1.0
|
||||
client_req.header(key.as_str(), value.as_bytes())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn upload(
|
||||
req: HttpRequest,
|
||||
body: Payload,
|
||||
// require login
|
||||
local_user_view: LocalUserView,
|
||||
client: Data<ClientWithMiddleware>,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
// TODO: check rate limit here
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
let image_url = format!("{}image", pictrs_config.url);
|
||||
|
||||
let mut client_req = adapt_request(&req, &client, image_url);
|
||||
|
||||
if let Some(addr) = req.head().peer_addr {
|
||||
client_req = client_req.header("X-Forwarded-For", addr.to_string())
|
||||
};
|
||||
let res = client_req
|
||||
.timeout(Duration::from_secs(pictrs_config.upload_timeout))
|
||||
.body(Body::wrap_stream(make_send(body)))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = res.status();
|
||||
let images = res.json::<PictrsResponse>().await?;
|
||||
if let Some(images) = &images.files {
|
||||
for image in images {
|
||||
let form = LocalImageForm {
|
||||
local_user_id: Some(local_user_view.local_user.id),
|
||||
pictrs_alias: image.file.to_string(),
|
||||
pictrs_delete_token: image.delete_token.to_string(),
|
||||
};
|
||||
|
||||
let protocol_and_hostname = context.settings().get_protocol_and_hostname();
|
||||
let thumbnail_url = image.thumbnail_url(&protocol_and_hostname)?;
|
||||
|
||||
// Also store the details for the image
|
||||
let details_form = image.details.build_image_details_form(&thumbnail_url);
|
||||
LocalImage::create(&mut context.pool(), &form, &details_form).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::build(convert_status(status)).json(images))
|
||||
}
|
||||
|
||||
async fn full_res(
|
||||
filename: Path<String>,
|
||||
Query(params): Query<PictrsGetParams>,
|
||||
req: HttpRequest,
|
||||
client: Data<ClientWithMiddleware>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: Option<LocalUserView>,
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
// block access to images if instance is private and unauthorized, public
|
||||
let local_site = LocalSite::read(&mut context.pool()).await?;
|
||||
if local_site.private_instance && local_user_view.is_none() {
|
||||
return Ok(HttpResponse::Unauthorized().finish());
|
||||
}
|
||||
let name = &filename.into_inner();
|
||||
|
||||
// If there are no query params, the URL is original
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
|
||||
let processed_url = params.process_url(name, &pictrs_config.url);
|
||||
|
||||
image(processed_url, req, &client).await
|
||||
}
|
||||
|
||||
async fn image(
|
||||
url: String,
|
||||
req: HttpRequest,
|
||||
client: &ClientWithMiddleware,
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
let mut client_req = adapt_request(&req, client, url);
|
||||
|
||||
if let Some(addr) = req.head().peer_addr {
|
||||
client_req = client_req.header("X-Forwarded-For", addr.to_string());
|
||||
}
|
||||
|
||||
if let Some(addr) = req.head().peer_addr {
|
||||
client_req = client_req.header("X-Forwarded-For", addr.to_string());
|
||||
}
|
||||
|
||||
let res = client_req.send().await?;
|
||||
|
||||
if res.status() == http::StatusCode::NOT_FOUND {
|
||||
return Ok(HttpResponse::NotFound().finish());
|
||||
}
|
||||
|
||||
let mut client_res = HttpResponse::build(StatusCode::from_u16(res.status().as_u16())?);
|
||||
|
||||
for (name, value) in res.headers().iter().filter(|(h, _)| *h != "connection") {
|
||||
client_res.insert_header(convert_header(name, value));
|
||||
}
|
||||
|
||||
Ok(client_res.body(BodyStream::new(res.bytes_stream())))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
components: Path<(String, String)>,
|
||||
req: HttpRequest,
|
||||
client: Data<ClientWithMiddleware>,
|
||||
context: Data<LemmyContext>,
|
||||
// require login
|
||||
_local_user_view: LocalUserView,
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
let (token, file) = components.into_inner();
|
||||
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
let url = format!("{}image/delete/{}/{}", pictrs_config.url, &token, &file);
|
||||
|
||||
let mut client_req = adapt_request(&req, &client, url);
|
||||
|
||||
if let Some(addr) = req.head().peer_addr {
|
||||
client_req = client_req.header("X-Forwarded-For", addr.to_string());
|
||||
}
|
||||
|
||||
let res = client_req.send().await?;
|
||||
|
||||
LocalImage::delete_by_alias(&mut context.pool(), &file).await?;
|
||||
|
||||
Ok(HttpResponse::build(convert_status(res.status())).body(BodyStream::new(res.bytes_stream())))
|
||||
}
|
||||
|
||||
async fn healthz(
|
||||
req: HttpRequest,
|
||||
client: Data<ClientWithMiddleware>,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
let url = format!("{}healthz", pictrs_config.url);
|
||||
|
||||
let mut client_req = adapt_request(&req, &client, url);
|
||||
|
||||
if let Some(addr) = req.head().peer_addr {
|
||||
client_req = client_req.header("X-Forwarded-For", addr.to_string());
|
||||
}
|
||||
|
||||
let res = client_req.send().await?;
|
||||
|
||||
Ok(HttpResponse::build(convert_status(res.status())).body(BodyStream::new(res.bytes_stream())))
|
||||
}
|
||||
|
||||
pub async fn image_proxy(
|
||||
Query(params): Query<ImageProxyParams>,
|
||||
req: HttpRequest,
|
||||
client: Data<ClientWithMiddleware>,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Either<HttpResponse<()>, HttpResponse<BoxBody>>> {
|
||||
let url = Url::parse(¶ms.url)?;
|
||||
|
||||
// Check that url corresponds to a federated image so that this can't be abused as a proxy
|
||||
// for arbitrary purposes.
|
||||
RemoteImage::validate(&mut context.pool(), url.clone().into()).await?;
|
||||
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
let processed_url = params.process_url(¶ms.url, &pictrs_config.url);
|
||||
|
||||
let bypass_proxy = pictrs_config
|
||||
.proxy_bypass_domains
|
||||
.iter()
|
||||
.any(|s| url.domain().is_some_and(|d| d == s));
|
||||
if bypass_proxy {
|
||||
// Bypass proxy and redirect user to original image
|
||||
Ok(Either::Left(Redirect::to(url.to_string()).respond_to(&req)))
|
||||
} else {
|
||||
// Proxy the image data through Lemmy
|
||||
Ok(Either::Right(image(processed_url, req, &client).await?))
|
||||
}
|
||||
}
|
||||
|
||||
fn make_send<S>(mut stream: S) -> impl Stream<Item = S::Item> + Send + Unpin + 'static
|
||||
where
|
||||
S: Stream + Unpin + 'static,
|
||||
S::Item: Send,
|
||||
{
|
||||
// NOTE: the 8 here is arbitrary
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(8);
|
||||
|
||||
// NOTE: spawning stream into a new task can potentially hit this bug:
|
||||
// - https://github.com/actix/actix-web/issues/1679
|
||||
//
|
||||
// Since 4.0.0-beta.2 this issue is incredibly less frequent. I have not personally reproduced it.
|
||||
// That said, it is still technically possible to encounter.
|
||||
actix_web::rt::spawn(async move {
|
||||
while let Some(res) = stream.next().await {
|
||||
if tx.send(res).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
SendStream { rx }
|
||||
}
|
||||
|
||||
struct SendStream<T> {
|
||||
rx: tokio::sync::mpsc::Receiver<T>,
|
||||
}
|
||||
|
||||
impl<T> Stream for SendStream<T>
|
||||
where
|
||||
T: Send,
|
||||
{
|
||||
type Item = T;
|
||||
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
std::pin::Pin::new(&mut self.rx).poll_recv(cx)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: remove these conversions after actix-web upgrades to http 1.0
|
||||
#[allow(clippy::expect_used)]
|
||||
fn convert_status(status: http::StatusCode) -> StatusCode {
|
||||
StatusCode::from_u16(status.as_u16()).expect("status can be converted")
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
fn convert_method(method: &Method) -> http::Method {
|
||||
http::Method::from_bytes(method.as_str().as_bytes()).expect("method can be converted")
|
||||
}
|
||||
|
||||
fn convert_header<'a>(name: &'a http::HeaderName, value: &'a HeaderValue) -> (&'a str, &'a [u8]) {
|
||||
(name.as_str(), value.as_bytes())
|
||||
}
|
147
crates/routes/src/images/delete.rs
Normal file
147
crates/routes/src/images/delete.rs
Normal file
|
@ -0,0 +1,147 @@
|
|||
use super::utils::delete_old_image;
|
||||
use actix_web::web::*;
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
image::{CommunityIdQuery, DeleteImageParams},
|
||||
utils::{is_admin, is_mod_or_admin},
|
||||
SuccessResponse,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
community::{Community, CommunityUpdateForm},
|
||||
images::LocalImage,
|
||||
person::{Person, PersonUpdateForm},
|
||||
site::{Site, SiteUpdateForm},
|
||||
},
|
||||
traits::Crud,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn delete_site_icon(
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let site = Site::read_local(&mut context.pool()).await?;
|
||||
is_admin(&local_user_view)?;
|
||||
|
||||
delete_old_image(&site.icon, &context).await?;
|
||||
|
||||
let form = SiteUpdateForm {
|
||||
icon: Some(None),
|
||||
..Default::default()
|
||||
};
|
||||
Site::update(&mut context.pool(), site.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
pub async fn delete_site_banner(
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let site = Site::read_local(&mut context.pool()).await?;
|
||||
is_admin(&local_user_view)?;
|
||||
|
||||
delete_old_image(&site.banner, &context).await?;
|
||||
|
||||
let form = SiteUpdateForm {
|
||||
banner: Some(None),
|
||||
..Default::default()
|
||||
};
|
||||
Site::update(&mut context.pool(), site.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn delete_community_icon(
|
||||
data: Json<CommunityIdQuery>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let community = Community::read(&mut context.pool(), data.id).await?;
|
||||
is_mod_or_admin(&mut context.pool(), &local_user_view.person, community.id).await?;
|
||||
|
||||
delete_old_image(&community.icon, &context).await?;
|
||||
|
||||
let form = CommunityUpdateForm {
|
||||
icon: Some(None),
|
||||
..Default::default()
|
||||
};
|
||||
Community::update(&mut context.pool(), community.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn delete_community_banner(
|
||||
data: Json<CommunityIdQuery>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let community = Community::read(&mut context.pool(), data.id).await?;
|
||||
is_mod_or_admin(&mut context.pool(), &local_user_view.person, community.id).await?;
|
||||
|
||||
delete_old_image(&community.icon, &context).await?;
|
||||
|
||||
let form = CommunityUpdateForm {
|
||||
icon: Some(None),
|
||||
..Default::default()
|
||||
};
|
||||
Community::update(&mut context.pool(), community.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn delete_user_avatar(
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
delete_old_image(&local_user_view.person.avatar, &context).await?;
|
||||
|
||||
let form = PersonUpdateForm {
|
||||
avatar: Some(None),
|
||||
..Default::default()
|
||||
};
|
||||
Person::update(&mut context.pool(), local_user_view.person.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn delete_user_banner(
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
delete_old_image(&local_user_view.person.banner, &context).await?;
|
||||
|
||||
let form = PersonUpdateForm {
|
||||
banner: Some(None),
|
||||
..Default::default()
|
||||
};
|
||||
Person::update(&mut context.pool(), local_user_view.person.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
// TODO: get rid of delete tokens and allow deletion by admin or uploader
|
||||
pub async fn delete_image(
|
||||
data: Json<DeleteImageParams>,
|
||||
context: Data<LemmyContext>,
|
||||
// require login
|
||||
_local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let pictrs_config = context.settings().pictrs()?;
|
||||
let url = format!(
|
||||
"{}image/delete/{}/{}",
|
||||
pictrs_config.url, &data.token, &data.filename
|
||||
);
|
||||
|
||||
context
|
||||
.pictrs_client()
|
||||
.delete(url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
LocalImage::delete_by_alias(&mut context.pool(), &data.filename).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
129
crates/routes/src/images/download.rs
Normal file
129
crates/routes/src/images/download.rs
Normal file
|
@ -0,0 +1,129 @@
|
|||
use super::utils::{adapt_request, convert_header};
|
||||
use actix_web::{
|
||||
body::{BodyStream, BoxBody},
|
||||
http::StatusCode,
|
||||
web::{Data, *},
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
Responder,
|
||||
};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
image::{ImageGetParams, ImageProxyParams},
|
||||
};
|
||||
use lemmy_db_schema::source::{images::RemoteImage, local_site::LocalSite};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
use url::Url;
|
||||
|
||||
pub async fn get_image(
|
||||
filename: Path<String>,
|
||||
Query(params): Query<ImageGetParams>,
|
||||
req: HttpRequest,
|
||||
local_user_view: Option<LocalUserView>,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
// block access to images if instance is private
|
||||
if local_user_view.is_none() {
|
||||
let local_site = LocalSite::read(&mut context.pool()).await?;
|
||||
if local_site.private_instance {
|
||||
return Ok(HttpResponse::Unauthorized().finish());
|
||||
}
|
||||
}
|
||||
let name = &filename.into_inner();
|
||||
|
||||
// If there are no query params, the URL is original
|
||||
let pictrs_url = context.settings().pictrs()?.url;
|
||||
let processed_url = if params.file_type.is_none() && params.max_size.is_none() {
|
||||
format!("{}image/original/{}", pictrs_url, name)
|
||||
} else {
|
||||
let file_type = file_type(params.file_type, name);
|
||||
let mut url = format!("{}image/process.{}?src={}", pictrs_url, file_type, name);
|
||||
|
||||
if let Some(size) = params.max_size {
|
||||
url = format!("{url}&thumbnail={size}",);
|
||||
}
|
||||
url
|
||||
};
|
||||
|
||||
do_get_image(processed_url, req, &context).await
|
||||
}
|
||||
|
||||
pub async fn image_proxy(
|
||||
Query(params): Query<ImageProxyParams>,
|
||||
req: HttpRequest,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Either<HttpResponse<()>, HttpResponse<BoxBody>>> {
|
||||
let url = Url::parse(¶ms.url)?;
|
||||
|
||||
// Check that url corresponds to a federated image so that this can't be abused as a proxy
|
||||
// for arbitrary purposes.
|
||||
RemoteImage::validate(&mut context.pool(), url.clone().into()).await?;
|
||||
|
||||
let pictrs_config = context.settings().pictrs()?;
|
||||
let processed_url = if params.file_type.is_none() && params.max_size.is_none() {
|
||||
format!("{}image/original?proxy={}", pictrs_config.url, params.url)
|
||||
} else {
|
||||
let file_type = file_type(params.file_type, url.as_str());
|
||||
let mut url = format!(
|
||||
"{}image/process.{}?proxy={}",
|
||||
pictrs_config.url, file_type, url
|
||||
);
|
||||
|
||||
if let Some(size) = params.max_size {
|
||||
url = format!("{url}&thumbnail={size}",);
|
||||
}
|
||||
url
|
||||
};
|
||||
|
||||
let bypass_proxy = pictrs_config
|
||||
.proxy_bypass_domains
|
||||
.iter()
|
||||
.any(|s| url.domain().is_some_and(|d| d == s));
|
||||
if bypass_proxy {
|
||||
// Bypass proxy and redirect user to original image
|
||||
Ok(Either::Left(Redirect::to(url.to_string()).respond_to(&req)))
|
||||
} else {
|
||||
// Proxy the image data through Lemmy
|
||||
Ok(Either::Right(
|
||||
do_get_image(processed_url, req, &context).await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn do_get_image(
|
||||
url: String,
|
||||
req: HttpRequest,
|
||||
context: &LemmyContext,
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
let mut client_req = adapt_request(&req, url, context);
|
||||
|
||||
if let Some(addr) = req.head().peer_addr {
|
||||
client_req = client_req.header("X-Forwarded-For", addr.to_string());
|
||||
}
|
||||
|
||||
if let Some(addr) = req.head().peer_addr {
|
||||
client_req = client_req.header("X-Forwarded-For", addr.to_string());
|
||||
}
|
||||
|
||||
let res = client_req.send().await?;
|
||||
|
||||
if res.status() == http::StatusCode::NOT_FOUND {
|
||||
return Ok(HttpResponse::NotFound().finish());
|
||||
}
|
||||
|
||||
let mut client_res = HttpResponse::build(StatusCode::from_u16(res.status().as_u16())?);
|
||||
|
||||
for (name, value) in res.headers().iter().filter(|(h, _)| *h != "connection") {
|
||||
client_res.insert_header(convert_header(name, value));
|
||||
}
|
||||
|
||||
Ok(client_res.body(BodyStream::new(res.bytes_stream())))
|
||||
}
|
||||
|
||||
/// Take file type from param, name, or use jpg if nothing is given
|
||||
pub(super) fn file_type(file_type: Option<String>, name: &str) -> String {
|
||||
file_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| name.split('.').last().unwrap_or("jpg").to_string())
|
||||
}
|
22
crates/routes/src/images/mod.rs
Normal file
22
crates/routes/src/images/mod.rs
Normal file
|
@ -0,0 +1,22 @@
|
|||
use actix_web::web::*;
|
||||
use lemmy_api_common::{context::LemmyContext, SuccessResponse};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub mod delete;
|
||||
pub mod download;
|
||||
pub mod upload;
|
||||
mod utils;
|
||||
|
||||
pub async fn pictrs_health(context: Data<LemmyContext>) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let pictrs_config = context.settings().pictrs()?;
|
||||
let url = format!("{}healthz", pictrs_config.url);
|
||||
|
||||
context
|
||||
.pictrs_client()
|
||||
.get(url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
239
crates/routes/src/images/upload.rs
Normal file
239
crates/routes/src/images/upload.rs
Normal file
|
@ -0,0 +1,239 @@
|
|||
use super::utils::{adapt_request, delete_old_image, make_send};
|
||||
use actix_web::{self, web::*, HttpRequest};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
image::{CommunityIdQuery, UploadImageResponse},
|
||||
request::PictrsResponse,
|
||||
utils::{is_admin, is_mod_or_admin},
|
||||
LemmyErrorType,
|
||||
SuccessResponse,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
community::{Community, CommunityUpdateForm},
|
||||
images::{LocalImage, LocalImageForm},
|
||||
person::{Person, PersonUpdateForm},
|
||||
site::{Site, SiteUpdateForm},
|
||||
},
|
||||
traits::Crud,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
use reqwest::Body;
|
||||
use std::time::Duration;
|
||||
use UploadType::*;
|
||||
|
||||
pub enum UploadType {
|
||||
Avatar,
|
||||
Banner,
|
||||
Other,
|
||||
}
|
||||
|
||||
pub async fn upload_image(
|
||||
req: HttpRequest,
|
||||
body: Payload,
|
||||
local_user_view: LocalUserView,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<UploadImageResponse>> {
|
||||
if context.settings().pictrs()?.image_upload_disabled {
|
||||
return Err(LemmyErrorType::ImageUploadDisabled.into());
|
||||
}
|
||||
|
||||
Ok(Json(
|
||||
do_upload_image(req, body, Other, &local_user_view, &context).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn upload_user_avatar(
|
||||
req: HttpRequest,
|
||||
body: Payload,
|
||||
local_user_view: LocalUserView,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let image = do_upload_image(req, body, Avatar, &local_user_view, &context).await?;
|
||||
delete_old_image(&local_user_view.person.avatar, &context).await?;
|
||||
|
||||
let form = PersonUpdateForm {
|
||||
avatar: Some(Some(image.image_url.into())),
|
||||
..Default::default()
|
||||
};
|
||||
Person::update(&mut context.pool(), local_user_view.person.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn upload_user_banner(
|
||||
req: HttpRequest,
|
||||
body: Payload,
|
||||
local_user_view: LocalUserView,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let image = do_upload_image(req, body, Banner, &local_user_view, &context).await?;
|
||||
delete_old_image(&local_user_view.person.banner, &context).await?;
|
||||
|
||||
let form = PersonUpdateForm {
|
||||
banner: Some(Some(image.image_url.into())),
|
||||
..Default::default()
|
||||
};
|
||||
Person::update(&mut context.pool(), local_user_view.person.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn upload_community_icon(
|
||||
req: HttpRequest,
|
||||
query: Query<CommunityIdQuery>,
|
||||
body: Payload,
|
||||
local_user_view: LocalUserView,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let community: Community = Community::read(&mut context.pool(), query.id).await?;
|
||||
is_mod_or_admin(&mut context.pool(), &local_user_view.person, community.id).await?;
|
||||
|
||||
let image = do_upload_image(req, body, Avatar, &local_user_view, &context).await?;
|
||||
delete_old_image(&community.icon, &context).await?;
|
||||
|
||||
let form = CommunityUpdateForm {
|
||||
icon: Some(Some(image.image_url.into())),
|
||||
..Default::default()
|
||||
};
|
||||
Community::update(&mut context.pool(), community.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn upload_community_banner(
|
||||
req: HttpRequest,
|
||||
query: Query<CommunityIdQuery>,
|
||||
body: Payload,
|
||||
local_user_view: LocalUserView,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let community: Community = Community::read(&mut context.pool(), query.id).await?;
|
||||
is_mod_or_admin(&mut context.pool(), &local_user_view.person, community.id).await?;
|
||||
|
||||
let image = do_upload_image(req, body, Banner, &local_user_view, &context).await?;
|
||||
delete_old_image(&community.banner, &context).await?;
|
||||
|
||||
let form = CommunityUpdateForm {
|
||||
banner: Some(Some(image.image_url.into())),
|
||||
..Default::default()
|
||||
};
|
||||
Community::update(&mut context.pool(), community.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn upload_site_icon(
|
||||
req: HttpRequest,
|
||||
body: Payload,
|
||||
local_user_view: LocalUserView,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
is_admin(&local_user_view)?;
|
||||
let site = Site::read_local(&mut context.pool()).await?;
|
||||
|
||||
let image = do_upload_image(req, body, Avatar, &local_user_view, &context).await?;
|
||||
delete_old_image(&site.icon, &context).await?;
|
||||
|
||||
let form = SiteUpdateForm {
|
||||
icon: Some(Some(image.image_url.into())),
|
||||
..Default::default()
|
||||
};
|
||||
Site::update(&mut context.pool(), site.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn upload_site_banner(
|
||||
req: HttpRequest,
|
||||
body: Payload,
|
||||
local_user_view: LocalUserView,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
is_admin(&local_user_view)?;
|
||||
let site = Site::read_local(&mut context.pool()).await?;
|
||||
|
||||
let image = do_upload_image(req, body, Banner, &local_user_view, &context).await?;
|
||||
delete_old_image(&site.banner, &context).await?;
|
||||
|
||||
let form = SiteUpdateForm {
|
||||
banner: Some(Some(image.image_url.into())),
|
||||
..Default::default()
|
||||
};
|
||||
Site::update(&mut context.pool(), site.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
pub async fn do_upload_image(
|
||||
req: HttpRequest,
|
||||
body: Payload,
|
||||
upload_type: UploadType,
|
||||
local_user_view: &LocalUserView,
|
||||
context: &Data<LemmyContext>,
|
||||
) -> LemmyResult<UploadImageResponse> {
|
||||
let pictrs = context.settings().pictrs()?;
|
||||
let image_url = format!("{}image", pictrs.url);
|
||||
|
||||
let mut client_req = adapt_request(&req, image_url, context);
|
||||
|
||||
client_req = match upload_type {
|
||||
Avatar => {
|
||||
let max_size = pictrs.max_avatar_size.to_string();
|
||||
client_req.query(&[
|
||||
("resize", max_size.as_ref()),
|
||||
("allow_animation", "false"),
|
||||
("allow_video", "false"),
|
||||
])
|
||||
}
|
||||
Banner => {
|
||||
let max_size = pictrs.max_banner_size.to_string();
|
||||
client_req.query(&[
|
||||
("resize", max_size.as_ref()),
|
||||
("allow_animation", "false"),
|
||||
("allow_video", "false"),
|
||||
])
|
||||
}
|
||||
_ => client_req,
|
||||
};
|
||||
if let Some(addr) = req.head().peer_addr {
|
||||
client_req = client_req.header("X-Forwarded-For", addr.to_string())
|
||||
};
|
||||
let res = client_req
|
||||
.timeout(Duration::from_secs(pictrs.upload_timeout))
|
||||
.body(Body::wrap_stream(make_send(body)))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let mut images = res.json::<PictrsResponse>().await?;
|
||||
for image in &images.files {
|
||||
// Pictrs allows uploading multiple images in a single request. Lemmy doesnt need this,
|
||||
// but still a user may upload multiple and so we need to store all links in db for
|
||||
// to allow deletion via web ui.
|
||||
let form = LocalImageForm {
|
||||
local_user_id: Some(local_user_view.local_user.id),
|
||||
pictrs_alias: image.file.to_string(),
|
||||
pictrs_delete_token: image.delete_token.to_string(),
|
||||
};
|
||||
|
||||
let protocol_and_hostname = context.settings().get_protocol_and_hostname();
|
||||
let thumbnail_url = image.image_url(&protocol_and_hostname)?;
|
||||
|
||||
// Also store the details for the image
|
||||
let details_form = image.details.build_image_details_form(&thumbnail_url);
|
||||
LocalImage::create(&mut context.pool(), &form, &details_form).await?;
|
||||
}
|
||||
let image = images
|
||||
.files
|
||||
.pop()
|
||||
.ok_or(LemmyErrorType::InvalidImageUpload)?;
|
||||
|
||||
let url = image.image_url(&context.settings().get_protocol_and_hostname())?;
|
||||
Ok(UploadImageResponse {
|
||||
image_url: url,
|
||||
filename: image.file,
|
||||
delete_token: image.delete_token,
|
||||
})
|
||||
}
|
111
crates/routes/src/images/utils.rs
Normal file
111
crates/routes/src/images/utils.rs
Normal file
|
@ -0,0 +1,111 @@
|
|||
use actix_web::{
|
||||
http::{
|
||||
header::{HeaderName, ACCEPT_ENCODING, HOST},
|
||||
Method,
|
||||
},
|
||||
web::Data,
|
||||
HttpRequest,
|
||||
};
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use http::HeaderValue;
|
||||
use lemmy_api_common::{context::LemmyContext, request::delete_image_from_pictrs};
|
||||
use lemmy_db_schema::{newtypes::DbUrl, source::images::LocalImage};
|
||||
use lemmy_utils::{error::LemmyResult, REQWEST_TIMEOUT};
|
||||
use reqwest_middleware::RequestBuilder;
|
||||
|
||||
pub(super) fn adapt_request(
|
||||
request: &HttpRequest,
|
||||
url: String,
|
||||
context: &LemmyContext,
|
||||
) -> RequestBuilder {
|
||||
// remove accept-encoding header so that pictrs doesn't compress the response
|
||||
const INVALID_HEADERS: &[HeaderName] = &[ACCEPT_ENCODING, HOST];
|
||||
|
||||
let client_request = context
|
||||
.pictrs_client()
|
||||
.request(convert_method(request.method()), url)
|
||||
.timeout(REQWEST_TIMEOUT);
|
||||
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.fold(client_request, |client_req, (key, value)| {
|
||||
if INVALID_HEADERS.contains(key) {
|
||||
client_req
|
||||
} else {
|
||||
// TODO: remove as_str and as_bytes conversions after actix-web upgrades to http 1.0
|
||||
client_req.header(key.as_str(), value.as_bytes())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn make_send<S>(mut stream: S) -> impl Stream<Item = S::Item> + Send + Unpin + 'static
|
||||
where
|
||||
S: Stream + Unpin + 'static,
|
||||
S::Item: Send,
|
||||
{
|
||||
// NOTE: the 8 here is arbitrary
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(8);
|
||||
|
||||
// NOTE: spawning stream into a new task can potentially hit this bug:
|
||||
// - https://github.com/actix/actix-web/issues/1679
|
||||
//
|
||||
// Since 4.0.0-beta.2 this issue is incredibly less frequent. I have not personally reproduced it.
|
||||
// That said, it is still technically possible to encounter.
|
||||
actix_web::rt::spawn(async move {
|
||||
while let Some(res) = stream.next().await {
|
||||
if tx.send(res).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
SendStream { rx }
|
||||
}
|
||||
|
||||
struct SendStream<T> {
|
||||
rx: tokio::sync::mpsc::Receiver<T>,
|
||||
}
|
||||
|
||||
impl<T> Stream for SendStream<T>
|
||||
where
|
||||
T: Send,
|
||||
{
|
||||
type Item = T;
|
||||
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
std::pin::Pin::new(&mut self.rx).poll_recv(cx)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: remove these conversions after actix-web upgrades to http 1.0
|
||||
#[allow(clippy::expect_used)]
|
||||
pub(super) fn convert_method(method: &Method) -> http::Method {
|
||||
http::Method::from_bytes(method.as_str().as_bytes()).expect("method can be converted")
|
||||
}
|
||||
|
||||
pub(super) fn convert_header<'a>(
|
||||
name: &'a http::HeaderName,
|
||||
value: &'a HeaderValue,
|
||||
) -> (&'a str, &'a [u8]) {
|
||||
(name.as_str(), value.as_bytes())
|
||||
}
|
||||
|
||||
/// When adding a new avatar, banner or similar image, delete the old one.
|
||||
pub(super) async fn delete_old_image(
|
||||
old_image: &Option<DbUrl>,
|
||||
context: &Data<LemmyContext>,
|
||||
) -> LemmyResult<()> {
|
||||
if let Some(old_image) = old_image {
|
||||
let image = LocalImage::delete_by_url(&mut context.pool(), old_image)
|
||||
.await
|
||||
.ok();
|
||||
if let Some(image) = image {
|
||||
delete_image_from_pictrs(&image.pictrs_alias, &image.pictrs_delete_token, context).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
|
@ -23,7 +23,6 @@ pub enum LemmyErrorType {
|
|||
CouldntUpdateComment,
|
||||
CouldntUpdatePrivateMessage,
|
||||
CannotLeaveAdmin,
|
||||
// TODO: also remove the translations of unused errors
|
||||
PictrsResponseError(String),
|
||||
PictrsPurgeResponseError(String),
|
||||
ImageUrlMissingPathSegments,
|
||||
|
@ -31,6 +30,8 @@ pub enum LemmyErrorType {
|
|||
PictrsApiKeyNotProvided,
|
||||
NoContentTypeHeader,
|
||||
NotAnImageType,
|
||||
InvalidImageUpload,
|
||||
ImageUploadDisabled,
|
||||
NotAModOrAdmin,
|
||||
NotTopMod,
|
||||
NotLoggedIn,
|
||||
|
|
|
@ -97,7 +97,7 @@ impl Settings {
|
|||
WEBFINGER_REGEX.clone()
|
||||
}
|
||||
|
||||
pub fn pictrs_config(&self) -> LemmyResult<PictrsConfig> {
|
||||
pub fn pictrs(&self) -> LemmyResult<PictrsConfig> {
|
||||
self
|
||||
.pictrs
|
||||
.clone()
|
||||
|
|
|
@ -98,6 +98,20 @@ pub struct PictrsConfig {
|
|||
/// Resize post thumbnails to this maximum width/height.
|
||||
#[default(512)]
|
||||
pub max_thumbnail_size: u32,
|
||||
|
||||
/// Maximum size for user avatar, community icon and site icon.
|
||||
#[default(512)]
|
||||
pub max_avatar_size: u32,
|
||||
|
||||
/// Maximum size for user, community and site banner. Larger images are downscaled to fit
|
||||
/// into a square of this size.
|
||||
#[default(1024)]
|
||||
pub max_banner_size: u32,
|
||||
|
||||
/// Prevent users from uploading images for posts or embedding in markdown. Avatars, icons and
|
||||
/// banners can still be uploaded.
|
||||
#[default(false)]
|
||||
pub image_upload_disabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document, PartialEq)]
|
||||
|
@ -113,7 +127,7 @@ pub enum PictrsImageMode {
|
|||
/// This behaviour matches Lemmy 0.18.
|
||||
StoreLinkPreviews,
|
||||
/// If enabled, all images from remote domains are rewritten to pass through
|
||||
/// `/api/v4/image_proxy`, including embedded images in markdown. Images are stored temporarily
|
||||
/// `/api/v4/image/proxy`, including embedded images in markdown. Images are stored temporarily
|
||||
/// in pict-rs for caching. This improves privacy as users don't expose their IP to untrusted
|
||||
/// servers, and decreases load on other servers. However it increases bandwidth use for the
|
||||
/// local server.
|
||||
|
|
|
@ -18,7 +18,7 @@ pub fn markdown_rewrite_image_links(mut src: String) -> (String, Vec<Url>) {
|
|||
// If link points to remote domain, replace with proxied link
|
||||
if parsed.domain() != Some(&SETTINGS.hostname) {
|
||||
let mut proxied = format!(
|
||||
"{}/api/v4/image_proxy?url={}",
|
||||
"{}/api/v4/image/proxy?url={}",
|
||||
SETTINGS.get_protocol_and_hostname(),
|
||||
encode(url),
|
||||
);
|
||||
|
@ -116,7 +116,7 @@ mod tests {
|
|||
(
|
||||
"remote image proxied",
|
||||
"![link](http://example.com/image.jpg)",
|
||||
"![link](https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)",
|
||||
"![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)",
|
||||
),
|
||||
(
|
||||
"local image unproxied",
|
||||
|
@ -126,7 +126,7 @@ mod tests {
|
|||
(
|
||||
"multiple image links",
|
||||
"![link](http://example.com/image1.jpg) ![link](http://example.com/image2.jpg)",
|
||||
"![link](https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Fexample.com%2Fimage1.jpg) ![link](https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Fexample.com%2Fimage2.jpg)",
|
||||
"![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage1.jpg) ![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage2.jpg)",
|
||||
),
|
||||
(
|
||||
"empty link handled",
|
||||
|
@ -136,7 +136,7 @@ mod tests {
|
|||
(
|
||||
"empty label handled",
|
||||
"![](http://example.com/image.jpg)",
|
||||
"![](https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)"
|
||||
"![](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)"
|
||||
),
|
||||
(
|
||||
"invalid image link removed",
|
||||
|
@ -146,12 +146,12 @@ mod tests {
|
|||
(
|
||||
"label with nested markdown handled",
|
||||
"![a *b* c](http://example.com/image.jpg)",
|
||||
"![a *b* c](https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)"
|
||||
"![a *b* c](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)"
|
||||
),
|
||||
(
|
||||
"custom emoji support",
|
||||
r#"![party-blob](https://www.hexbear.net/pictrs/image/83405746-0620-4728-9358-5f51b040ffee.gif "emoji party-blob")"#,
|
||||
r#"![party-blob](https://lemmy-alpha/api/v4/image_proxy?url=https%3A%2F%2Fwww.hexbear.net%2Fpictrs%2Fimage%2F83405746-0620-4728-9358-5f51b040ffee.gif "emoji party-blob")"#
|
||||
r#"![party-blob](https://lemmy-alpha/api/v4/image/proxy?url=https%3A%2F%2Fwww.hexbear.net%2Fpictrs%2Fimage%2F83405746-0620-4728-9358-5f51b040ffee.gif "emoji party-blob")"#
|
||||
)
|
||||
];
|
||||
|
||||
|
|
|
@ -155,7 +155,7 @@ mod tests {
|
|||
(
|
||||
"remote image proxied",
|
||||
"![link](http://example.com/image.jpg)",
|
||||
"![link](https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)",
|
||||
"![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)",
|
||||
),
|
||||
(
|
||||
"local image unproxied",
|
||||
|
@ -165,7 +165,7 @@ mod tests {
|
|||
(
|
||||
"multiple image links",
|
||||
"![link](http://example.com/image1.jpg) ![link](http://example.com/image2.jpg)",
|
||||
"![link](https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Fexample.com%2Fimage1.jpg) ![link](https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Fexample.com%2Fimage2.jpg)",
|
||||
"![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage1.jpg) ![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage2.jpg)",
|
||||
),
|
||||
(
|
||||
"empty link handled",
|
||||
|
@ -175,7 +175,7 @@ mod tests {
|
|||
(
|
||||
"empty label handled",
|
||||
"![](http://example.com/image.jpg)",
|
||||
"![](https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)"
|
||||
"![](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)"
|
||||
),
|
||||
(
|
||||
"invalid image link removed",
|
||||
|
@ -185,12 +185,12 @@ mod tests {
|
|||
(
|
||||
"label with nested markdown handled",
|
||||
"![a *b* c](http://example.com/image.jpg)",
|
||||
"![a *b* c](https://lemmy-alpha/api/v4/image_proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)"
|
||||
"![a *b* c](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)"
|
||||
),
|
||||
(
|
||||
"custom emoji support",
|
||||
r#"![party-blob](https://www.hexbear.net/pictrs/image/83405746-0620-4728-9358-5f51b040ffee.gif "emoji party-blob")"#,
|
||||
r#"![party-blob](https://lemmy-alpha/api/v4/image_proxy?url=https%3A%2F%2Fwww.hexbear.net%2Fpictrs%2Fimage%2F83405746-0620-4728-9358-5f51b040ffee.gif "emoji party-blob")"#
|
||||
r#"![party-blob](https://lemmy-alpha/api/v4/image/proxy?url=https%3A%2F%2Fwww.hexbear.net%2Fpictrs%2Fimage%2F83405746-0620-4728-9358-5f51b040ffee.gif "emoji party-blob")"#
|
||||
)
|
||||
];
|
||||
|
||||
|
|
|
@ -77,7 +77,6 @@ use lemmy_api::{
|
|||
unread_count::get_unread_registration_application_count,
|
||||
},
|
||||
},
|
||||
sitemap::get_sitemap,
|
||||
};
|
||||
use lemmy_api_crud::{
|
||||
comment::{
|
||||
|
@ -124,14 +123,27 @@ use lemmy_apub::api::{
|
|||
search::search,
|
||||
user_settings_backup::{export_settings, import_settings},
|
||||
};
|
||||
use lemmy_routes::images::image_proxy;
|
||||
use lemmy_routes::images::{
|
||||
delete::delete_image,
|
||||
download::{get_image, image_proxy},
|
||||
pictrs_health,
|
||||
upload::upload_image,
|
||||
};
|
||||
use lemmy_utils::rate_limit::RateLimitCell;
|
||||
|
||||
// Deprecated, use api v4 instead.
|
||||
// When removing api v3, we also need to rewrite all links in database with
|
||||
// `/api/v3/image_proxy` to use `/api/v4/image_proxy` instead.
|
||||
// When removing api v3, make sure to keep `/api/v3/image_proxy` as it is still used in old posts.
|
||||
pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
|
||||
cfg.service(
|
||||
cfg
|
||||
.service(
|
||||
resource("/pictrs/image")
|
||||
.wrap(rate_limit.image())
|
||||
.route(post().to(upload_image)),
|
||||
)
|
||||
.service(resource("/pictrs/image/{filename}").route(get().to(get_image)))
|
||||
.service(resource("/pictrs/image/delete/{token}/{filename}").route(get().to(delete_image)))
|
||||
.service(resource("/pictrs/healthz").route(get().to(pictrs_health)))
|
||||
.service(
|
||||
scope("/api/v3")
|
||||
.route("/image_proxy", get().to(image_proxy))
|
||||
// Site
|
||||
|
@ -291,7 +303,8 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
|
|||
.wrap(rate_limit.import_user_settings())
|
||||
.route(post().to(import_settings)),
|
||||
)
|
||||
// TODO, all the current account related actions under /user need to get moved here eventually
|
||||
// TODO, all the current account related actions under /user need to get moved here
|
||||
// eventually
|
||||
.service(
|
||||
scope("/account")
|
||||
.wrap(rate_limit.message())
|
||||
|
@ -367,9 +380,4 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
|
|||
.route("/delete", post().to(delete_custom_emoji)),
|
||||
),
|
||||
);
|
||||
cfg.service(
|
||||
scope("/sitemap.xml")
|
||||
.wrap(rate_limit.message())
|
||||
.route("", get().to(get_sitemap)),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -88,7 +88,6 @@ use lemmy_api::{
|
|||
unread_count::get_unread_registration_application_count,
|
||||
},
|
||||
},
|
||||
sitemap::get_sitemap,
|
||||
};
|
||||
use lemmy_api_crud::{
|
||||
comment::{
|
||||
|
@ -152,20 +151,44 @@ use lemmy_apub::api::{
|
|||
search::search,
|
||||
user_settings_backup::{export_settings, import_settings},
|
||||
};
|
||||
use lemmy_routes::images::image_proxy;
|
||||
use lemmy_routes::images::{
|
||||
delete::{
|
||||
delete_community_banner,
|
||||
delete_community_icon,
|
||||
delete_image,
|
||||
delete_site_banner,
|
||||
delete_site_icon,
|
||||
delete_user_avatar,
|
||||
delete_user_banner,
|
||||
},
|
||||
download::{get_image, image_proxy},
|
||||
pictrs_health,
|
||||
upload::{
|
||||
upload_community_banner,
|
||||
upload_community_icon,
|
||||
upload_image,
|
||||
upload_site_banner,
|
||||
upload_site_icon,
|
||||
upload_user_avatar,
|
||||
upload_user_banner,
|
||||
},
|
||||
};
|
||||
use lemmy_utils::rate_limit::RateLimitCell;
|
||||
|
||||
pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
|
||||
cfg.service(
|
||||
scope("/api/v4")
|
||||
.wrap(rate_limit.message())
|
||||
.route("/image_proxy", get().to(image_proxy))
|
||||
// Site
|
||||
.service(
|
||||
scope("/site")
|
||||
.route("", get().to(get_site_v4))
|
||||
.route("", post().to(create_site))
|
||||
.route("", put().to(update_site)),
|
||||
.route("", put().to(update_site))
|
||||
.route("/icon", post().to(upload_site_icon))
|
||||
.route("/icon", delete().to(delete_site_icon))
|
||||
.route("/banner", post().to(upload_site_banner))
|
||||
.route("/banner", delete().to(delete_site_banner)),
|
||||
)
|
||||
.route("/modlog", get().to(get_mod_log))
|
||||
.service(
|
||||
|
@ -195,6 +218,10 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
|
|||
.route("/transfer", post().to(transfer_community))
|
||||
.route("/ban_user", post().to(ban_from_community))
|
||||
.route("/mod", post().to(add_mod_to_community))
|
||||
.route("/icon", post().to(upload_community_icon))
|
||||
.route("/icon", delete().to(delete_community_icon))
|
||||
.route("/banner", post().to(upload_community_banner))
|
||||
.route("/banner", delete().to(delete_community_banner))
|
||||
.service(
|
||||
scope("/pending_follows")
|
||||
.route("/count", get().to(get_pending_follows_count))
|
||||
|
@ -313,6 +340,10 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
|
|||
.route("/unread_count", get().to(unread_count))
|
||||
.route("/list_logins", get().to(list_logins))
|
||||
.route("/validate_auth", get().to(validate_auth))
|
||||
.route("/avatar", post().to(upload_user_avatar))
|
||||
.route("/avatar", delete().to(delete_user_avatar))
|
||||
.route("/banner", post().to(upload_user_banner))
|
||||
.route("/banner", delete().to(delete_user_banner))
|
||||
.service(
|
||||
scope("/block")
|
||||
.route("/person", post().to(user_block_person))
|
||||
|
@ -388,6 +419,17 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
|
|||
.wrap(rate_limit.register())
|
||||
.route("/authenticate", post().to(authenticate_with_oauth)),
|
||||
)
|
||||
.route("/sitemap.xml", get().to(get_sitemap)),
|
||||
.service(
|
||||
scope("/image")
|
||||
.service(
|
||||
resource("")
|
||||
.wrap(rate_limit.image())
|
||||
.route(post().to(upload_image))
|
||||
.route(delete().to(delete_image)),
|
||||
)
|
||||
.route("/proxy", get().to(image_proxy))
|
||||
.route("/health", get().to(pictrs_health))
|
||||
.route("/{filename}", get().to(get_image)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
26
src/lib.rs
26
src/lib.rs
|
@ -11,13 +11,14 @@ use actix_cors::Cors;
|
|||
use actix_web::{
|
||||
dev::{ServerHandle, ServiceResponse},
|
||||
middleware::{self, Condition, ErrorHandlerResponse, ErrorHandlers},
|
||||
web::Data,
|
||||
web::{get, scope, Data},
|
||||
App,
|
||||
HttpResponse,
|
||||
HttpServer,
|
||||
};
|
||||
use actix_web_prom::PrometheusMetricsBuilder;
|
||||
use clap::{Parser, Subcommand};
|
||||
use lemmy_api::sitemap::get_sitemap;
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
lemmy_db_views::structs::SiteView,
|
||||
|
@ -36,7 +37,7 @@ use lemmy_apub::{
|
|||
};
|
||||
use lemmy_db_schema::{schema_setup, source::secret::Secret, utils::build_db_pool};
|
||||
use lemmy_federate::{Opts, SendManager};
|
||||
use lemmy_routes::{feeds, images, nodeinfo, webfinger};
|
||||
use lemmy_routes::{feeds, nodeinfo, webfinger};
|
||||
use lemmy_utils::{
|
||||
error::{LemmyErrorType, LemmyResult},
|
||||
rate_limit::RateLimitCell,
|
||||
|
@ -194,9 +195,13 @@ pub async fn start_lemmy_server(args: CmdArgs) -> LemmyResult<()> {
|
|||
let client = ClientBuilder::new(client_builder(&SETTINGS).build()?)
|
||||
.with(TracingMiddleware::default())
|
||||
.build();
|
||||
let pictrs_client = ClientBuilder::new(client_builder(&SETTINGS).no_proxy().build()?)
|
||||
.with(TracingMiddleware::default())
|
||||
.build();
|
||||
let context = LemmyContext::create(
|
||||
pool.clone(),
|
||||
client.clone(),
|
||||
pictrs_client,
|
||||
secret.clone(),
|
||||
rate_limit_cell.clone(),
|
||||
);
|
||||
|
@ -329,17 +334,12 @@ fn create_http_server(
|
|||
.build()
|
||||
.map_err(|e| LemmyErrorType::Unknown(format!("Should always be buildable: {e}")))?;
|
||||
|
||||
let context: LemmyContext = federation_config.deref().clone();
|
||||
let rate_limit_cell = federation_config.rate_limit_cell().clone();
|
||||
|
||||
// Pictrs cannot use proxy
|
||||
let pictrs_client = ClientBuilder::new(client_builder(&SETTINGS).no_proxy().build()?)
|
||||
.with(TracingMiddleware::default())
|
||||
.build();
|
||||
|
||||
// Create Http server
|
||||
let bind = (settings.bind, settings.port);
|
||||
let server = HttpServer::new(move || {
|
||||
let context: LemmyContext = federation_config.deref().clone();
|
||||
let rate_limit_cell = federation_config.rate_limit_cell().clone();
|
||||
|
||||
let cors_config = cors_config(&settings);
|
||||
let app = App::new()
|
||||
.wrap(middleware::Logger::new(
|
||||
|
@ -372,8 +372,12 @@ fn create_http_server(
|
|||
}
|
||||
})
|
||||
.configure(feeds::config)
|
||||
.configure(|cfg| images::config(cfg, pictrs_client.clone(), &rate_limit_cell))
|
||||
.configure(nodeinfo::config)
|
||||
.service(
|
||||
scope("/sitemap.xml")
|
||||
.wrap(rate_limit_cell.message())
|
||||
.route("", get().to(get_sitemap)),
|
||||
)
|
||||
})
|
||||
.disable_signals()
|
||||
.bind(bind)?
|
||||
|
|
Loading…
Reference in a new issue