import hashlib import io import secrets from django.contrib import messages from django.contrib.auth import authenticate, get_user_model, login, logout, update_session_auth_hash from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from common.markdown import render_markdown from library.models import FileIndex from .forms import AvatarForm, BioForm, EmailForm, UsernameForm from .models import ApiToken, UserProfile def register(request): if request.method == 'POST': form = UserCreationForm(request.POST) if form.is_valid(): user = form.save() UserProfile.objects.get_or_create(user=user) login(request, user) messages.success(request, 'Registration successful.') return redirect('landing:home') else: form = UserCreationForm() return render(request, 'profiles/register.html', {'form': form}) def user_login(request): if request.method == 'POST': form = AuthenticationForm(request, data=request.POST) if form.is_valid(): user = form.get_user() login(request, user) return redirect('landing:home') else: form = AuthenticationForm(request) return render(request, 'profiles/login.html', {'form': form}) def user_logout(request): if request.method == 'POST': was_authorized = request.session.get('authorized', False) logout(request) # logout() flushes the session (wiping the gate flag). The gate is a # site-wide master password, separate from user accounts — preserve it. if was_authorized: request.session['authorized'] = True return redirect('landing:home') return redirect('landing:home') @login_required def settings_page(request): tokens = request.user.api_tokens.all() context = {'tokens': tokens} return render(request, 'profiles/settings.html', context) @login_required def token_create(request): if request.method == 'POST': label = request.POST.get('label', '').strip() or 'API token' token = secrets.token_urlsafe(48) ApiToken.objects.create( user=request.user, token=token, key_prefix=token[:8], label=label, ) messages.success(request, 'Token created.') return redirect('profiles:settings') @login_required def token_delete(request, token_id): ApiToken.objects.filter(id=token_id, user=request.user).delete() return redirect('profiles:settings') def _jpeg_bytes(uploaded): """Return JPEG bytes for an uploaded image. Already-JPEG uploads are stored as-is; anything else is re-encoded to JPEG via Pillow. """ import PIL.Image image = PIL.Image.open(uploaded) if image.format == 'JPEG': uploaded.seek(0) return uploaded.read() if image.mode not in ('RGB', 'L'): image = image.convert('RGB') buffer = io.BytesIO() image.save(buffer, format='JPEG', quality=88) return buffer.getvalue() def _store_avatar(user, uploaded): """Persist an avatar under /avatars/user_/picture.jpeg, index it in FileIndex, and return the FileIndex row (replacing any previous avatar).""" profile, _ = UserProfile.objects.get_or_create(user=user) old_index = profile.avatar if old_index is not None: if old_index.stored_path and default_storage.exists(old_index.stored_path): default_storage.delete(old_index.stored_path) FileIndex.objects.filter(pk=old_index.pk).delete() profile.avatar = None data = _jpeg_bytes(uploaded) stored_path = f'avatars/user_{user.pk}/picture.jpeg' actual_path = default_storage.save(stored_path, ContentFile(data)) file_index = FileIndex.objects.create( owner=user, kind='avatar', stored_path=actual_path, original_filename=getattr(uploaded, 'name', '') or 'picture.jpeg', content_type='image/jpeg', size=len(data), md5=hashlib.md5(data).hexdigest(), ) profile.avatar = file_index profile.save(update_fields=['avatar']) return file_index @login_required def account_page(request): username_form = UsernameForm(instance=request.user) email_form = EmailForm(instance=request.user) password_form = PasswordChangeForm(request.user) avatar_form = AvatarForm() if request.method == 'POST': action = request.POST.get('action') if action == 'username': username_form = UsernameForm(request.POST, instance=request.user) if username_form.is_valid(): username_form.save() messages.success(request, 'Username updated.') return redirect('profiles:account') messages.error(request, 'Could not update username.') elif action == 'email': email_form = EmailForm(request.POST, instance=request.user) if email_form.is_valid(): email_form.save() messages.success(request, 'Email updated.') return redirect('profiles:account') messages.error(request, 'Could not update email.') elif action == 'password': password_form = PasswordChangeForm(request.user, request.POST) if password_form.is_valid(): user = password_form.save() update_session_auth_hash(request, user) messages.success(request, 'Password updated.') return redirect('profiles:account') messages.error(request, 'Could not update password.') elif action == 'avatar': avatar_form = AvatarForm(request.POST, request.FILES) if avatar_form.is_valid(): _store_avatar(request.user, avatar_form.cleaned_data['avatar']) messages.success(request, 'Profile picture updated.') return redirect('profiles:account') messages.error(request, 'Could not update profile picture.') context = { 'username_form': username_form, 'email_form': email_form, 'password_form': password_form, 'avatar_form': avatar_form, } return render(request, 'profiles/account.html', context) def user_profile(request, username): User = get_user_model() user = get_object_or_404(User, username__iexact=username) profile, created = UserProfile.objects.get_or_create(user=user) is_owner = request.user.is_authenticated and request.user.pk == user.pk bio_form = None if is_owner: if request.method == 'POST' and request.POST.get('action') == 'bio': bio_form = BioForm(request.POST, instance=profile) if bio_form.is_valid(): bio_form.save() messages.success(request, 'Bio updated.') return redirect('profiles:user_profile', username=user.username) messages.error(request, 'Could not update bio.') else: bio_form = BioForm(instance=profile) return render(request, 'profiles/profile.html', { 'profile': profile, 'profile_user': user, 'is_owner': is_owner, 'bio_form': bio_form, 'projects': user.projects.select_related('owner', 'thumbnail').prefetch_related( 'tag_links__tag__category', 'versions', ).all(), }) @login_required def bio_preview(request): """Return the markdown-rendered HTML for a bio draft (editor live preview).""" if request.method != 'POST': return JsonResponse({'detail': 'Method not allowed'}, status=405) text = request.POST.get('bio', '') return JsonResponse({'html': render_markdown(text)})