Profile pic upload: chunked upload (512 KB chunks), 5 MB limit, progress bar, graceful errors

- Add /dashboard/profile/upload-pic/chunk endpoint: assembles chunks server-side,
  validates MIME type and 5 MB limit, base64-encodes and stores in DB + session
- Remove profile_pic from the form POST (was limited by proxy body size limits)
- Profile template: file input triggers JS chunked upload, shows progress bar and
  inline status; client-side pre-check for size/type before any network request
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 70ecd9e0
...@@ -3277,39 +3277,101 @@ async def dashboard_profile(request: Request): ...@@ -3277,39 +3277,101 @@ async def dashboard_profile(request: Request):
@app.post("/dashboard/profile") @app.post("/dashboard/profile")
async def dashboard_profile_save(request: Request, username: str = Form(...), display_name: str = Form(""), profile_pic: UploadFile = File(None)): async def dashboard_profile_save(request: Request, username: str = Form(...), display_name: str = Form("")):
"""Save user profile changes (username and display_name)""" """Save user profile changes (username and display_name). Profile pic is handled separately via /dashboard/profile/upload-pic/chunk."""
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse): if isinstance(auth_check, RedirectResponse):
return auth_check return auth_check
user_id = request.session.get('user_id') user_id = request.session.get('user_id')
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
try: try:
profile_pic_data = None db.update_user_profile(user_id, username, None, display_name if display_name else None, None)
if profile_pic and profile_pic.filename:
content_type = profile_pic.content_type or ''
if not content_type.startswith('image/'):
return RedirectResponse(url=url_for(request, "/dashboard/profile?error=Invalid file type. Please upload an image."), status_code=303)
data = await profile_pic.read(1024 * 1024 + 1) # read up to 1MB+1 to detect oversized
if len(data) > 1024 * 1024:
return RedirectResponse(url=url_for(request, "/dashboard/profile?error=Image too large. Maximum size is 1MB."), status_code=303)
import base64
profile_pic_data = f"data:{content_type};base64,{base64.b64encode(data).decode()}"
db.update_user_profile(user_id, username, None, display_name if display_name else None, profile_pic_data)
# Update session with new username, display_name, and profile_pic
request.session['username'] = username request.session['username'] = username
request.session['display_name'] = display_name or '' request.session['display_name'] = display_name or ''
if profile_pic_data is not None:
request.session['profile_pic'] = profile_pic_data
return RedirectResponse(url=url_for(request, "/dashboard/profile?success=Profile updated successfully"), status_code=303) return RedirectResponse(url=url_for(request, "/dashboard/profile?success=Profile updated successfully"), status_code=303)
except Exception as e: except Exception as e:
return RedirectResponse(url=url_for(request, f"/dashboard/profile?error=Failed to update profile: {str(e)}"), status_code=303) return RedirectResponse(url=url_for(request, f"/dashboard/profile?error=Failed to update profile: {str(e)}"), status_code=303)
_PROFILE_PIC_MAX_BYTES = 5 * 1024 * 1024 # 5 MB assembled limit
@app.post("/dashboard/profile/upload-pic/chunk")
async def dashboard_profile_pic_chunk(
request: Request,
file_name: str = Form(...),
chunk_number: int = Form(...),
total_chunks: int = Form(...),
total_size: int = Form(...),
file: UploadFile = File(...)
):
"""Chunked profile picture upload. Assembles chunks, validates, base64-encodes, stores in DB."""
auth_check = require_dashboard_auth(request)
if auth_check:
return JSONResponse({"success": False, "error": "Unauthorized"}, status_code=401)
user_id = request.session.get('user_id')
if total_size > _PROFILE_PIC_MAX_BYTES:
return JSONResponse({"success": False, "error": f"Image too large. Maximum size is 5 MB."}, status_code=400)
content_type = file.content_type or ''
if not content_type.startswith('image/'):
# Fallback: infer from extension
ext = Path(file_name).suffix.lower()
ext_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp'}
content_type = ext_map.get(ext, '')
if not content_type:
return JSONResponse({"success": False, "error": "Invalid file type. Upload JPG, PNG, GIF or WebP."}, status_code=400)
import hashlib as _hl
upload_id = _hl.sha256(f"{user_id}:{file_name}:{total_size}".encode()).hexdigest()[:16]
temp_dir = Path.home() / '.aisbf' / 'temp_uploads' / 'profile_pics'
temp_dir.mkdir(parents=True, exist_ok=True)
chunk_data = await file.read()
chunk_path = temp_dir / f"{upload_id}.part{chunk_number}"
with open(chunk_path, 'wb') as f:
f.write(chunk_data)
received = list(temp_dir.glob(f"{upload_id}.part*"))
if len(received) < total_chunks:
return JSONResponse({"success": True, "complete": False, "chunk": chunk_number})
# All chunks received — assemble
try:
assembled = bytearray()
for i in range(1, total_chunks + 1):
part = temp_dir / f"{upload_id}.part{i}"
assembled.extend(part.read_bytes())
part.unlink()
if len(assembled) > _PROFILE_PIC_MAX_BYTES:
return JSONResponse({"success": False, "error": "Assembled image exceeds 5 MB limit."}, status_code=400)
import base64 as _b64
data_url = f"data:{content_type};base64,{_b64.b64encode(bytes(assembled)).decode()}"
db = DatabaseRegistry.get_config_database()
db.update_user_profile(user_id, request.session.get('username', ''), None, None, data_url)
request.session['profile_pic'] = data_url
return JSONResponse({"success": True, "complete": True})
except Exception as e:
logger.error(f"Profile pic assembly error for user {user_id}: {e}")
# Clean up any remaining parts
for part in temp_dir.glob(f"{upload_id}.part*"):
try:
part.unlink()
except Exception:
pass
return JSONResponse({"success": False, "error": "Upload failed. Please try again."}, status_code=500)
@app.get("/dashboard/change-password", response_class=HTMLResponse) @app.get("/dashboard/change-password", response_class=HTMLResponse)
async def dashboard_change_password(request: Request): async def dashboard_change_password(request: Request):
"""Change user password page""" """Change user password page"""
......
...@@ -17,8 +17,8 @@ ...@@ -17,8 +17,8 @@
<div class="card"> <div class="card">
<h2>Account Information</h2> <h2>Account Information</h2>
<form method="POST" action="{{ url_for(request, '/dashboard/profile') }}" enctype="multipart/form-data"> <form method="POST" action="{{ url_for(request, '/dashboard/profile') }}">
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ session.username }}" required> <input type="text" id="username" name="username" value="{{ session.username }}" required>
...@@ -48,21 +48,26 @@ ...@@ -48,21 +48,26 @@
<div class="form-group"> <div class="form-group">
<label>Profile Picture</label> <label>Profile Picture</label>
<div style="display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;"> <div style="display: flex; align-items: flex-start; gap: 1.5rem; flex-wrap: wrap;">
<div style="position: relative; cursor: pointer;" onclick="document.getElementById('profile_pic').click()"> <div style="position: relative; cursor: pointer; flex-shrink: 0;" id="avatar-wrap">
{% if user.profile_pic %} <img id="avatar-preview"
<img id="avatar-preview" src="{{ user.profile_pic }}" alt="Profile picture" style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;"> src="{{ user.profile_pic if user.profile_pic else 'https://www.gravatar.com/avatar/' ~ (session.email|md5 if session.email else '') ~ '?s=96&d=identicon' }}"
{% else %} alt="Profile picture"
<img id="avatar-preview" src="https://www.gravatar.com/avatar/{{ session.email|md5 }}?s=96&d=identicon" alt="Profile picture" style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;"> style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;">
{% endif %} <div id="avatar-overlay" style="position: absolute; inset: 0; background: rgba(0,0,0,0.45); border-radius: 8px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s; pointer-events: none;">
<div style="position: absolute; inset: 0; background: rgba(0,0,0,0.45); border-radius: 8px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s;" id="avatar-overlay">
<span style="color: #fff; font-size: 0.8rem; text-align: center;">Change</span> <span style="color: #fff; font-size: 0.8rem; text-align: center;">Change</span>
</div> </div>
</div> </div>
<div> <div style="flex: 1; min-width: 0;">
<input type="file" id="profile_pic" name="profile_pic" accept="image/*" style="display: none;" onchange="previewAvatar(this)"> <input type="file" id="profile_pic_file" accept="image/*" style="display: none;">
<button type="button" class="btn" style="background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0;" onclick="document.getElementById('profile_pic').click()">Upload Image</button> <button type="button" id="pic-upload-btn" class="btn" style="background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0;" onclick="document.getElementById('profile_pic_file').click()">
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">Max 1 MB. JPG, PNG, GIF, WebP.</small> Upload Image
</button>
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">Max 5 MB. JPG, PNG, GIF, WebP.</small>
<div id="pic-upload-status" style="margin-top: 0.5rem; font-size: 0.85rem; display: none;"></div>
<div id="pic-progress-bar" style="margin-top: 0.5rem; height: 6px; background: #0f3460; border-radius: 3px; display: none;">
<div id="pic-progress-fill" style="height: 100%; background: #4a9eff; border-radius: 3px; width: 0%; transition: width 0.2s;"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -87,51 +92,118 @@ ...@@ -87,51 +92,118 @@
border-radius: 8px; border-radius: 8px;
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.card h2 { margin-bottom: 1.5rem; color: #e0e0e0; }
.card h2 { .form-group { margin-bottom: 1.25rem; }
margin-bottom: 1.5rem; .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: #e0e0e0; }
color: #e0e0e0;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #e0e0e0;
}
.form-group input { .form-group input {
width: 100%; width: 100%; padding: 0.75rem; border: 1px solid #0f3460; border-radius: 4px;
padding: 0.75rem; background: #1a1a2e; color: #e0e0e0; font-size: 1rem;
border: 1px solid #0f3460;
border-radius: 4px;
background: #1a1a2e;
color: #e0e0e0;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
} }
.form-group input:focus { outline: none; border-color: #667eea; }
</style> </style>
<script> <script>
function previewAvatar(input) { const CHUNK_SIZE = 512 * 1024; // 512 KB — same as providers
if (!input.files || !input.files[0]) return;
const avatarWrap = document.getElementById('avatar-wrap');
const overlay = document.getElementById('avatar-overlay');
avatarWrap.addEventListener('mouseenter', () => overlay.style.opacity = '1');
avatarWrap.addEventListener('mouseleave', () => overlay.style.opacity = '0');
avatarWrap.addEventListener('click', () => document.getElementById('profile_pic_file').click());
document.getElementById('profile_pic_file').addEventListener('change', async function () {
const file = this.files && this.files[0];
if (!file) return;
const MAX_BYTES = 5 * 1024 * 1024;
const statusEl = document.getElementById('pic-upload-status');
const barWrap = document.getElementById('pic-progress-bar');
const barFill = document.getElementById('pic-progress-fill');
const btn = document.getElementById('pic-upload-btn');
function setStatus(msg, color) {
statusEl.style.color = color || '#e0e0e0';
statusEl.textContent = msg;
statusEl.style.display = 'block';
}
function setProgress(pct) {
barWrap.style.display = 'block';
barFill.style.width = pct + '%';
}
// Client-side size check
if (file.size > MAX_BYTES) {
setStatus('Image is too large. Maximum size is 5 MB.', '#f87171');
this.value = '';
return;
}
// Client-side type check
if (!file.type.startsWith('image/')) {
setStatus('Invalid file type. Please upload JPG, PNG, GIF or WebP.', '#f87171');
this.value = '';
return;
}
// Preview immediately
const reader = new FileReader(); const reader = new FileReader();
reader.onload = e => { document.getElementById('avatar-preview').src = e.target.result; }; reader.onload = e => { document.getElementById('avatar-preview').src = e.target.result; };
reader.readAsDataURL(input.files[0]); reader.readAsDataURL(file);
}
const avatarWrap = document.querySelector('[onclick="document.getElementById(\'profile_pic\').click()"]'); btn.disabled = true;
const overlay = document.getElementById('avatar-overlay'); setProgress(0);
if (avatarWrap && overlay) { setStatus('Uploading…', '#4a9eff');
avatarWrap.addEventListener('mouseenter', () => overlay.style.opacity = '1');
avatarWrap.addEventListener('mouseleave', () => overlay.style.opacity = '0'); try {
} const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 1; i <= totalChunks; i++) {
const start = (i - 1) * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const pct = Math.round((i / totalChunks) * 100);
setProgress(pct);
setStatus(`Uploading… ${pct}%`, '#4a9eff');
const fd = new FormData();
fd.append('file_name', file.name);
fd.append('chunk_number', i);
fd.append('total_chunks', totalChunks);
fd.append('total_size', file.size);
fd.append('file', chunk, file.name);
const resp = await fetch('/dashboard/profile/upload-pic/chunk', {
method: 'POST',
body: fd
});
if (!resp.ok) {
let msg = 'Upload failed (server error).';
try { msg = (await resp.json()).error || msg; } catch (_) {}
throw new Error(msg);
}
const result = await resp.json();
if (!result.success) throw new Error(result.error || 'Upload failed.');
if (result.complete) {
setProgress(100);
setStatus('Profile picture updated!', '#4ade80');
btn.disabled = false;
return;
}
}
// Fallback — shouldn't reach here
setStatus('Upload complete.', '#4ade80');
} catch (err) {
setStatus('Upload failed: ' + err.message, '#f87171');
barWrap.style.display = 'none';
} finally {
btn.disabled = false;
this.value = '';
}
});
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment