Skip to content

Commit f3d7de5

Browse files
committed
increase image upload limits
1 parent 11cdce4 commit f3d7de5

12 files changed

Lines changed: 190 additions & 42 deletions

File tree

backend/app.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,10 @@ def ensure_index(collection, keys, unique=True, **kwargs):
638638
db.topics.create_index([("is_public", 1), ("is_deleted", 1), ("member_count", -1)])
639639
db.topics.create_index([("is_public", 1), ("is_deleted", 1), ("created_at", -1)])
640640
db.topics.create_index([("is_public", 1), ("is_deleted", 1), ("tags", 1)])
641+
642+
# Index for pending deletions sorting
643+
db.topics.create_index([("is_deleted", 1), ("deletion_status", 1), ("deleted_at", -1)])
644+
641645
except Exception as e:
642646
logging.getLogger(__name__).warning(f"Failed to create some standard indexes: {e}")
643647

@@ -660,6 +664,10 @@ def ensure_index(collection, keys, unique=True, **kwargs):
660664
db.posts.create_index("upvotes")
661665
db.posts.create_index("downvotes") # New: downvotes
662666
db.posts.create_index("is_deleted")
667+
668+
# Index for pending deletions sorting
669+
db.posts.create_index([("is_deleted", 1), ("deletion_status", 1), ("deleted_at", -1)])
670+
663671

664672
# Comments collection indexes (new)
665673
db.comments.create_index([("post_id", 1), ("created_at", -1)])
@@ -683,6 +691,10 @@ def ensure_index(collection, keys, unique=True, **kwargs):
683691
db.chat_rooms.create_index("tags") # New: tags for search
684692
db.chat_rooms.create_index("is_public")
685693
db.chat_rooms.create_index("created_at")
694+
695+
# Index for pending deletions sorting
696+
db.chat_rooms.create_index([("is_deleted", 1), ("deletion_status", 1), ("deleted_at", -1)])
697+
686698

687699
# Messages collection indexes (updated)
688700
db.messages.create_index([("topic_id", 1), ("created_at", -1)]) # Legacy
@@ -693,6 +705,10 @@ def ensure_index(collection, keys, unique=True, **kwargs):
693705
db.messages.create_index("created_at")
694706
db.messages.create_index("mentions")
695707
db.messages.create_index("is_deleted")
708+
709+
# Index for deleted messages sorting
710+
db.messages.create_index([("is_deleted", 1), ("deleted_at", -1)])
711+
696712

697713
# Reports collection indexes
698714
db.reports.create_index([("reported_content_id", 1), ("content_type", 1)])

backend/routes/chat_rooms.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,29 @@ def create_group_chat():
156156
if not chat_room_model.check_name_unique(name, topic_id=None):
157157
return jsonify({'success': False, 'errors': ['A group chat with this name already exists']}), 409
158158

159+
# Process pictures if provided
160+
picture = data.get('picture')
161+
background_picture = data.get('background_picture')
162+
163+
from utils.image_compression import compress_image_base64
164+
from utils.imgbb_upload import process_image_for_storage
165+
166+
if picture:
167+
logger.info("Processing group chat picture...")
168+
compressed = compress_image_base64(picture)
169+
if compressed:
170+
storage_result = process_image_for_storage(compressed)
171+
if storage_result['success']:
172+
picture = storage_result['url']
173+
174+
if background_picture:
175+
logger.info("Processing group chat background picture...")
176+
compressed = compress_image_base64(background_picture)
177+
if compressed:
178+
storage_result = process_image_for_storage(compressed)
179+
if storage_result['success']:
180+
background_picture = storage_result['url']
181+
159182
# Create group chat
160183
room_id = chat_room_model.create_chat_room(
161184
topic_id=None,
@@ -164,8 +187,8 @@ def create_group_chat():
164187
owner_id=user_id,
165188
is_public=False, # Group chats are private by default usually, or invite only
166189
tags=[],
167-
picture=data.get('picture'),
168-
background_picture=data.get('background_picture')
190+
picture=picture,
191+
background_picture=background_picture
169192
)
170193

171194
# Invite users
@@ -288,6 +311,25 @@ def create_conversation(topic_id):
288311
picture = data.get('picture')
289312
background_picture = data.get('background_picture')
290313

314+
from utils.image_compression import compress_image_base64
315+
from utils.imgbb_upload import process_image_for_storage
316+
317+
if picture:
318+
logger.info("Processing chat room picture...")
319+
compressed = compress_image_base64(picture)
320+
if compressed:
321+
storage_result = process_image_for_storage(compressed)
322+
if storage_result['success']:
323+
picture = storage_result['url']
324+
325+
if background_picture:
326+
logger.info("Processing chat room background picture...")
327+
compressed = compress_image_base64(background_picture)
328+
if compressed:
329+
storage_result = process_image_for_storage(compressed)
330+
if storage_result['success']:
331+
background_picture = storage_result['url']
332+
291333
# Create conversation (chat room)
292334
chat_room_model = ChatRoom(current_app.db)
293335
room_id = chat_room_model.create_chat_room(

backend/routes/users.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,29 +87,50 @@ def update_user_profile():
8787

8888
if profile_picture is not None:
8989
if profile_picture:
90-
# Compress the image before storing
91-
logger.info("Compressing profile picture...")
90+
# 1. Compress the image (honors 25MB threshold)
91+
from utils.image_compression import MAX_PROFILE_PICTURE_SIZE_KB, compress_image_base64
92+
from utils.imgbb_upload import process_image_for_storage
93+
94+
logger.info("Processing profile picture...")
95+
# We still pass MAX_PROFILE_PICTURE_SIZE_KB as a hint,
96+
# but compress_image_base64 will only act if > 25MB unless we force it.
97+
# Actually, let's follow the user's "no mandatory compression under 25MB" strictly.
9298
compressed_image = compress_image_base64(profile_picture)
99+
93100
if compressed_image:
94-
update_data['profile_picture'] = compressed_image
95-
logger.info("Profile picture compressed successfully")
101+
# 2. Process for storage (Azure/ImgBB if still large or configured)
102+
storage_result = process_image_for_storage(compressed_image)
103+
if storage_result['success']:
104+
update_data['profile_picture'] = storage_result['url']
105+
logger.info(f"Profile picture processed successfully via {storage_result.get('source')}")
106+
else:
107+
update_data['profile_picture'] = compressed_image
96108
else:
97-
logger.warning("Image compression failed, storing original")
109+
logger.warning("Image processing failed, storing original")
98110
update_data['profile_picture'] = profile_picture
99111
else:
100112
# Remove profile picture if null/empty string
101113
update_data['profile_picture'] = None
102114

103115
if banner is not None:
104116
if banner:
105-
# Compress the banner image before storing
106-
logger.info("Compressing banner image...")
117+
# 1. Compress the banner (honors 25MB threshold)
118+
from utils.image_compression import compress_image_base64
119+
from utils.imgbb_upload import process_image_for_storage
120+
121+
logger.info("Processing banner image...")
107122
compressed_banner = compress_image_base64(banner)
123+
108124
if compressed_banner:
109-
update_data['banner'] = compressed_banner
110-
logger.info("Banner image compressed successfully")
125+
# 2. Process for storage (Azure/ImgBB if still large or configured)
126+
storage_result = process_image_for_storage(compressed_banner)
127+
if storage_result['success']:
128+
update_data['banner'] = storage_result['url']
129+
logger.info(f"Banner processed successfully via {storage_result.get('source')}")
130+
else:
131+
update_data['banner'] = compressed_banner
111132
else:
112-
logger.warning("Banner compression failed, storing original")
133+
logger.warning("Banner processing failed, storing original")
113134
update_data['banner'] = banner
114135
else:
115136
# Remove banner if null/empty string

backend/utils/image_compression.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
# Maximum dimensions for profile pictures
1414
MAX_PROFILE_PICTURE_WIDTH = 800
1515
MAX_PROFILE_PICTURE_HEIGHT = 800
16-
MAX_PROFILE_PICTURE_SIZE_KB = 500 # Target max size in KB
16+
MAX_PROFILE_PICTURE_SIZE_KB = 500 # Target max size in KB for avatars
17+
18+
# New limits for overall image uploads
19+
MAX_IMAGE_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
20+
COMPRESSION_THRESHOLD = 25 * 1024 * 1024 # 25MB
1721

1822
# Quality settings
1923
JPEG_QUALITY = 85 # Good balance between quality and size
@@ -24,8 +28,9 @@ def compress_image_base64(
2428
base64_string: str,
2529
max_width: int = MAX_PROFILE_PICTURE_WIDTH,
2630
max_height: int = MAX_PROFILE_PICTURE_HEIGHT,
27-
max_size_kb: int = MAX_PROFILE_PICTURE_SIZE_KB,
28-
quality: int = JPEG_QUALITY
31+
max_size_kb: Optional[int] = None, # Changed to Optional
32+
quality: int = JPEG_QUALITY,
33+
force_compression: bool = False
2934
) -> Optional[str]:
3035
"""
3136
Compress a base64-encoded image.
@@ -34,22 +39,38 @@ def compress_image_base64(
3439
base64_string: Base64-encoded image string (with or without data URI prefix)
3540
max_width: Maximum width in pixels
3641
max_height: Maximum height in pixels
37-
max_size_kb: Target maximum size in KB
42+
max_size_kb: Target maximum size in KB. If None, uses COMPRESSION_THRESHOLD logic.
3843
quality: JPEG quality (1-100, higher = better quality but larger file)
44+
force_compression: If True, always compress regardless of size
3945
4046
Returns:
4147
Compressed base64-encoded image string, or None if compression fails
4248
"""
4349
try:
4450
# Remove data URI prefix if present (e.g., "data:image/jpeg;base64,")
51+
prefix = ""
4552
if ',' in base64_string:
46-
base64_string = base64_string.split(',')[1]
53+
prefix, base64_string = base64_string.split(',', 1)
54+
prefix += ","
4755

4856
# Decode base64 to bytes
4957
image_bytes = base64.b64decode(base64_string)
50-
original_size_kb = len(image_bytes) / 1024
58+
original_size = len(image_bytes)
59+
original_size_kb = original_size / 1024
60+
61+
# Check against absolute limit
62+
if original_size > MAX_IMAGE_UPLOAD_SIZE:
63+
logger.error(f"Image too large: {original_size_kb:.2f} KB exceeds {MAX_IMAGE_UPLOAD_SIZE/1024/1024:.2f} MB")
64+
return None
65+
66+
# Determine if compression is needed
67+
needs_compression = force_compression or original_size > COMPRESSION_THRESHOLD or max_size_kb is not None
68+
69+
if not needs_compression:
70+
logger.info(f"Image size {original_size_kb:.2f} KB is under threshold. Skipping compression.")
71+
return prefix + base64_string
5172

52-
logger.info(f"Original image size: {original_size_kb:.2f} KB")
73+
logger.info(f"Compressing image. Original size: {original_size_kb:.2f} KB")
5374

5475
# Open image with PIL
5576
image = Image.open(io.BytesIO(image_bytes))
@@ -65,16 +86,22 @@ def compress_image_base64(
6586
elif image.mode != 'RGB':
6687
image = image.convert('RGB')
6788

68-
# Resize if necessary
69-
if image.width > max_width or image.height > max_height:
89+
# Resize if necessary (only if max_width/height are specifically small, like for avatars)
90+
# For general large images, we might want to keep the resolution unless it's insane.
91+
# If max_size_kb is set (e.g. 500KB for avatars), we resize.
92+
# Otherwise, if it's just > 25MB, we might just compress the quality first.
93+
if max_size_kb and (image.width > max_width or image.height > max_height):
7094
image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
7195
logger.info(f"Resized to: {image.width}x{image.height}")
7296

97+
# Target size in KB
98+
target_size_kb = max_size_kb if max_size_kb else 10240 # Default to 10MB if just over threshold
99+
73100
# Compress with quality adjustment
74101
output = io.BytesIO()
75102
current_quality = quality
76103

77-
# Try to get under max_size_kb by reducing quality if needed
104+
# Try to get under target_size_kb by reducing quality if needed
78105
for attempt in range(5): # Max 5 attempts
79106
output.seek(0)
80107
output.truncate(0)
@@ -85,20 +112,21 @@ def compress_image_base64(
85112
logger.info(f"Attempt {attempt + 1}: Quality={current_quality}, Size={compressed_size_kb:.2f} KB")
86113

87114
# If we're under the target size or quality is already low, we're done
88-
if compressed_size_kb <= max_size_kb or current_quality <= 50:
115+
if compressed_size_kb <= target_size_kb or current_quality <= 50:
89116
break
90117

91118
# Reduce quality for next attempt
92119
current_quality = max(50, int(current_quality * 0.85))
93120

94121
# Encode back to base64
95-
compressed_base64 = base64.b64encode(output.getvalue()).decode('utf-8')
96-
final_size_kb = len(output.getvalue()) / 1024
122+
compressed_bytes = output.getvalue()
123+
compressed_base64 = base64.b64encode(compressed_bytes).decode('utf-8')
124+
final_size_kb = len(compressed_bytes) / 1024
97125

98126
compression_ratio = (1 - (final_size_kb / original_size_kb)) * 100 if original_size_kb > 0 else 0
99127
logger.info(f"Compressed image: {final_size_kb:.2f} KB ({compression_ratio:.1f}% reduction)")
100128

101-
return compressed_base64
129+
return prefix + compressed_base64
102130

103131
except Exception as e:
104132
logger.error(f"Image compression failed: {str(e)}", exc_info=True)

backend/utils/imgbb_upload.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
logger = logging.getLogger(__name__)
88

99
IMGBB_UPLOAD_URL = 'https://api.imgbb.com/1/upload'
10-
DEFAULT_MAX_BASE64_SIZE = 2 * 1024 * 1024 # 2MB threshold for using imgbb
10+
DEFAULT_MAX_BASE64_SIZE = 25 * 1024 * 1024 # 25MB threshold for using imgbb or storage offloading
1111

1212

1313
def upload_to_imgbb(base64_image: str) -> dict:

frontend/components/Admin/DeletedMessagesModal.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ const DeletedMessagesModal: React.FC<DeletedMessagesModalProps> = ({ onClose })
5252
const [showWarnDialog, setShowWarnDialog] = useState(false);
5353
const [warnUserId, setWarnUserId] = useState<string>('');
5454
const [warnUsername, setWarnUsername] = useState<string>('');
55+
const [mounted, setMounted] = useState(false);
5556

5657
useEffect(() => {
58+
setMounted(true);
5759
loadDeletedMessages();
5860
}, []);
5961

@@ -126,7 +128,9 @@ const DeletedMessagesModal: React.FC<DeletedMessagesModalProps> = ({ onClose })
126128
setShowWarnDialog(true);
127129
};
128130

129-
return (
131+
if (!mounted) return null;
132+
133+
return createPortal(
130134
<>
131135
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={onClose}>
132136
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
@@ -390,7 +394,8 @@ const DeletedMessagesModal: React.FC<DeletedMessagesModalProps> = ({ onClose })
390394
}}
391395
/>
392396
)}
393-
</>
397+
</>,
398+
document.body
394399
);
395400
};
396401

frontend/components/Admin/PendingDeletionsModal.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState, useEffect } from 'react';
22
import { api, API_ENDPOINTS } from '@/utils/api';
33
import { useLanguage } from '@/contexts/LanguageContext';
4+
import { createPortal } from 'react-dom';
45
import { toast } from 'react-hot-toast';
56
import LoadingSpinner from '../UI/LoadingSpinner';
67
import Avatar from '../UI/Avatar';
@@ -31,8 +32,10 @@ const PendingDeletionsModal: React.FC<PendingDeletionsModalProps> = ({ onClose }
3132
const [selectedDeletion, setSelectedDeletion] = useState<PendingDeletion | null>(null);
3233

3334
const [lockDeletion, setLockDeletion] = useState(false);
35+
const [mounted, setMounted] = useState(false);
3436

3537
useEffect(() => {
38+
setMounted(true);
3639
loadPendingDeletions();
3740
}, []);
3841

@@ -176,7 +179,9 @@ const PendingDeletionsModal: React.FC<PendingDeletionsModalProps> = ({ onClose }
176179
return new Date(dateString).toLocaleString();
177180
};
178181

179-
return (
182+
if (!mounted) return null;
183+
184+
return createPortal(
180185
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={onClose}>
181186
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
182187
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
@@ -368,7 +373,8 @@ const PendingDeletionsModal: React.FC<PendingDeletionsModalProps> = ({ onClose }
368373
)}
369374
</div>
370375
</div>
371-
</div>
376+
</div>,
377+
document.body
372378
);
373379
};
374380

0 commit comments

Comments
 (0)