diff --git a/nonpacks/common/urls.py b/nonpacks/common/urls.py index c8a68ce..9a95066 100644 --- a/nonpacks/common/urls.py +++ b/nonpacks/common/urls.py @@ -1,16 +1,12 @@ """ URL configuration for common project. """ -from django.conf import settings -from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path urlpatterns = [ path('', include('landing.urls')), path('', include('profiles.urls')), + path('api/', include('library.urls')), path('admin/', admin.site.urls), ] - -if settings.DEBUG: - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/nonpacks/library/migrations/0001_initial.py b/nonpacks/library/migrations/0001_initial.py new file mode 100644 index 0000000..65a9e3f --- /dev/null +++ b/nonpacks/library/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.3 on 2026-08-03 18:14 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FileIndex', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('kind', models.CharField(db_index=True, default='file', max_length=64)), + ('stored_path', models.CharField(max_length=1024)), + ('original_filename', models.CharField(blank=True, default='', max_length=255)), + ('content_type', models.CharField(blank=True, default='', max_length=128)), + ('size', models.IntegerField(default=0)), + ('md5', models.CharField(db_index=True, max_length=32)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='files', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [models.Index(fields=['owner', 'kind'], name='library_fil_owner_i_c2b465_idx')], + }, + ), + ] diff --git a/nonpacks/library/models.py b/nonpacks/library/models.py index 71a8362..70d10c4 100644 --- a/nonpacks/library/models.py +++ b/nonpacks/library/models.py @@ -1,3 +1,37 @@ +import uuid + +from django.conf import settings from django.db import models -# Create your models here. + +class FileIndex(models.Model): + """Tracks every user file so media stays organized and served only + through the gated FileRequest endpoint (never direct /media/ access).""" + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='files', + ) + kind = models.CharField(max_length=64, default='file', db_index=True) + stored_path = models.CharField(max_length=1024) + original_filename = models.CharField(max_length=255, blank=True, default='') + content_type = models.CharField(max_length=128, blank=True, default='') + size = models.IntegerField(default=0) + md5 = models.CharField(max_length=32, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=['owner', 'kind']), + ] + + def __str__(self): + return f'{self.kind} {self.uuid} ({self.owner})' + + @property + def file_path(self): + return self.stored_path diff --git a/nonpacks/library/urls.py b/nonpacks/library/urls.py new file mode 100644 index 0000000..e14dd1f --- /dev/null +++ b/nonpacks/library/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = 'library' + +urlpatterns = [ + path('files//', views.file_request, name='file_request'), +] diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index 91ea44a..32699b7 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -1,3 +1,103 @@ -from django.shortcuts import render +import posixpath +from pathlib import Path -# Create your views here. +from django.conf import settings +from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse +from django.shortcuts import get_object_or_404 + +from .models import FileIndex + + +def _open_indexed_file(file_index): + """Return an open binary file handle for the indexed file (404-safe).""" + stored = file_index.stored_path + if not stored or posixpath.isabs(stored) or '..' in stored.split('/'): + raise Http404 + file_path = (Path(settings.MEDIA_ROOT) / stored).resolve() + if not file_path.is_relative_to(Path(settings.MEDIA_ROOT).resolve()): + raise Http404 + if not file_path.is_file(): + raise Http404 + return open(file_path, 'rb') + + +def _unsatisfiable(content_length): + resp = HttpResponse(status=416) + resp['Content-Range'] = f'bytes */{content_length}' + return resp + + +def file_request(request, file_id): + """Serve an indexed file behind the gate, honouring HTTP byte ranges. + + GET /api/files// → full file (200) + Range: bytes=0-99 → partial content (206) + Range: bytes=- → tail from + Range: bytes=- → last bytes + ?download=1 → Content-Disposition: attachment + """ + file_index = get_object_or_404(FileIndex, uuid=file_id) + fh = _open_indexed_file(file_index) + + content_type = file_index.content_type or 'application/octet-stream' + content_length = file_index.size or 0 + disposition = '' + if request.GET.get('download') == '1': + filename = file_index.original_filename or posixpath.basename(file_index.stored_path) + disposition = f'attachment; filename="{filename}"' + + range_header = request.META.get('HTTP_RANGE', '').strip() + if not range_header or not range_header.lower().startswith('bytes='): + response = FileResponse(fh, content_type=content_type) + response['Accept-Ranges'] = 'bytes' + response['Content-Length'] = str(content_length) + if disposition: + response['Content-Disposition'] = disposition + return response + + try: + start, _, end = range_header[6:].partition('-') + start = int(start) if start else None + end = int(end) if end else None + except ValueError: + return _unsatisfiable(content_length) + + if start is None and end is None: + return _unsatisfiable(content_length) + + if start is None: + suffix = end + if suffix <= 0: + return _unsatisfiable(content_length) + start = max(content_length - suffix, 0) + end = content_length - 1 + elif end is None: + end = content_length - 1 + + if start < 0 or start >= content_length or end < start: + return _unsatisfiable(content_length) + + end = min(end, content_length - 1) + length = end - start + 1 + fh.seek(start) + + def chunk_generator(): + try: + remaining = length + while remaining > 0: + data = fh.read(min(8192, remaining)) + if not data: + break + remaining -= len(data) + yield data + finally: + fh.close() + + part = StreamingHttpResponse(chunk_generator(), content_type=content_type) + part['Content-Range'] = f'bytes {start}-{end}/{content_length}' + part['Accept-Ranges'] = 'bytes' + part['Content-Length'] = str(length) + part.status_code = 206 + if disposition: + part['Content-Disposition'] = disposition + return part diff --git a/nonpacks/profiles/forms.py b/nonpacks/profiles/forms.py new file mode 100644 index 0000000..c5495a0 --- /dev/null +++ b/nonpacks/profiles/forms.py @@ -0,0 +1,53 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import PasswordChangeForm +from django import forms + +from .models import BIO_MAX_LENGTH, UserProfile + + +class UsernameForm(forms.ModelForm): + class Meta: + model = get_user_model() + fields = ['username'] + + def clean_username(self): + username = self.cleaned_data['username'] + User = get_user_model() + if User.objects.exclude(pk=self.instance.pk).filter(username=username).exists(): + raise forms.ValidationError('That username is already taken.') + return username + + +class EmailForm(forms.ModelForm): + email = forms.EmailField(required=False) + + class Meta: + model = get_user_model() + fields = ['email'] + + def clean_email(self): + return self.cleaned_data.get('email', '').strip() + + +class AvatarForm(forms.Form): + avatar = forms.ImageField( + label='Profile picture', + widget=forms.ClearableFileInput(attrs={'accept': 'image/*'}), + ) + + +class BioForm(forms.ModelForm): + bio = forms.CharField( + label='Bio', + required=False, + max_length=BIO_MAX_LENGTH, + widget=forms.Textarea(attrs={ + 'rows': 4, + 'placeholder': 'Tell people about yourself…', + 'maxlength': BIO_MAX_LENGTH, + }), + ) + + class Meta: + model = UserProfile + fields = ['bio'] diff --git a/nonpacks/profiles/migrations/0003_alter_userprofile_avatar.py b/nonpacks/profiles/migrations/0003_alter_userprofile_avatar.py new file mode 100644 index 0000000..8e660d6 --- /dev/null +++ b/nonpacks/profiles/migrations/0003_alter_userprofile_avatar.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.3 on 2026-08-03 18:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0001_initial'), + ('profiles', '0002_apitoken_profiles_ap_user_id_c2920d_idx'), + ] + + operations = [ + migrations.AlterField( + model_name='userprofile', + name='avatar', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='profile', to='library.fileindex'), + ), + ] diff --git a/nonpacks/profiles/migrations/0004_alter_userprofile_bio.py b/nonpacks/profiles/migrations/0004_alter_userprofile_bio.py new file mode 100644 index 0000000..e99f4ef --- /dev/null +++ b/nonpacks/profiles/migrations/0004_alter_userprofile_bio.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-08-03 18:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0003_alter_userprofile_avatar'), + ] + + operations = [ + migrations.AlterField( + model_name='userprofile', + name='bio', + field=models.CharField(blank=True, default='', max_length=1000), + ), + ] diff --git a/nonpacks/profiles/models.py b/nonpacks/profiles/models.py index d1bfaa3..e6b3b3b 100644 --- a/nonpacks/profiles/models.py +++ b/nonpacks/profiles/models.py @@ -1,5 +1,8 @@ from django.conf import settings from django.db import models +from django.urls import reverse + +BIO_MAX_LENGTH = 1000 class UserProfile(models.Model): @@ -8,14 +11,26 @@ class UserProfile(models.Model): on_delete=models.CASCADE, related_name='userprofile', ) - bio = models.TextField(blank=True, default='') - avatar = models.ImageField( - upload_to='profiles/%d/', + bio = models.CharField( + max_length=BIO_MAX_LENGTH, blank=True, + default='', + ) + avatar = models.OneToOneField( + 'library.FileIndex', + on_delete=models.SET_NULL, null=True, + blank=True, + related_name='profile', ) joined = models.DateTimeField(auto_now_add=True) + @property + def avatar_url(self): + if self.avatar_id is None: + return None + return reverse('library:file_request', args=[self.avatar.uuid]) + def __str__(self): return f'{self.user.username} profile' diff --git a/nonpacks/profiles/tests.py b/nonpacks/profiles/tests.py index b1cc61e..83202f3 100644 --- a/nonpacks/profiles/tests.py +++ b/nonpacks/profiles/tests.py @@ -1,9 +1,49 @@ +import io import secrets +import shutil +import tempfile from django.contrib.auth import get_user_model -from django.test import TestCase +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.urls import reverse -from .models import ApiToken, UserProfile +from library.models import FileIndex + +from .models import BIO_MAX_LENGTH, ApiToken, UserProfile + + +def _read_media(stored_path): + from django.core.files.storage import default_storage + + with default_storage.open(stored_path, 'rb') as f: + return f.read() + + +class MediaTestCase(TestCase): + """Base class that redirects uploaded media to a temp directory.""" + + @classmethod + def setUpClass(cls): + cls._media_root = tempfile.mkdtemp(prefix='packs_test_media_') + cls.override = override_settings(MEDIA_ROOT=cls._media_root) + cls.override.enable() + super().setUpClass() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.override.disable() + shutil.rmtree(cls._media_root, ignore_errors=True) + + +class GatedTestCase(TestCase): + """Base class with a helper to authorize the client past the gate.""" + + def gate(self): + session = self.client.session + session['authorized'] = True + session.save() class ApiTokenAuthTests(TestCase): @@ -74,3 +114,262 @@ class ApiTokenAuthTests(TestCase): ) token = ApiToken.objects.get(token=self.token) self.assertIsNotNone(token.last_used) + + +class AccountPageTests(MediaTestCase, GatedTestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username='Alice', password='pw') + UserProfile.objects.get_or_create(user=self.user) + + def test_account_page_requires_login(self): + self.gate() + resp = self.client.get(reverse('profiles:account')) + self.assertEqual(resp.status_code, 302) + + def test_account_page_renders_for_owner(self): + self.gate() + self.client.login(username='Alice', password='pw') + resp = self.client.get(reverse('profiles:account')) + self.assertEqual(resp.status_code, 200) + + def test_logout_preserves_gate_access(self): + self.gate() + self.client.login(username='Alice', password='pw') + resp = self.client.post(reverse('profiles:logout')) + self.assertRedirects(resp, '/home/') + self.assertFalse(self.client.session.get('_auth_user_id')) + # The gate flag must survive logout. + self.assertTrue(self.client.session.get('authorized')) + resp = self.client.get('/home/') + self.assertEqual(resp.status_code, 200) + + def test_username_change(self): + self.gate() + self.client.login(username='Alice', password='pw') + resp = self.client.post(reverse('profiles:account'), { + 'action': 'username', + 'username': 'Alicia', + }) + self.assertRedirects(resp, reverse('profiles:account')) + self.assertTrue(get_user_model().objects.filter(username='Alicia').exists()) + + def test_username_taken_rejected(self): + User = get_user_model() + User.objects.create_user(username='Bob', password='pw') + self.gate() + self.client.login(username='Alice', password='pw') + resp = self.client.post(reverse('profiles:account'), { + 'action': 'username', + 'username': 'Bob', + }) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'already taken') + + def test_email_blank_voids_field(self): + self.gate() + self.client.login(username='Alice', password='pw') + User = get_user_model() + User.objects.filter(pk=self.user.pk).update(email='alice@example.com') + resp = self.client.post(reverse('profiles:account'), { + 'action': 'email', + 'email': '', + }) + self.assertRedirects(resp, reverse('profiles:account')) + self.user.refresh_from_db() + self.assertEqual(self.user.email, '') + + def test_password_change(self): + self.gate() + self.client.login(username='Alice', password='pw') + resp = self.client.post(reverse('profiles:account'), { + 'action': 'password', + 'old_password': 'pw', + 'new_password1': 'new-secure-pass-123', + 'new_password2': 'new-secure-pass-123', + }) + self.assertRedirects(resp, reverse('profiles:account')) + self.assertTrue(self.client.login(username='Alice', password='new-secure-pass-123')) + + def test_avatar_upload_creates_file_index(self): + import PIL.Image + + self.gate() + self.client.login(username='Alice', password='pw') + buf = io.BytesIO() + PIL.Image.new('RGB', (8, 8), 'red').save(buf, format='PNG') + upload = SimpleUploadedFile('pic.png', buf.getvalue(), content_type='image/png') + resp = self.client.post(reverse('profiles:account'), { + 'action': 'avatar', + 'avatar': upload, + }) + self.assertRedirects(resp, reverse('profiles:account')) + self.user.userprofile.refresh_from_db() + self.assertIsNotNone(self.user.userprofile.avatar) + fi = FileIndex.objects.get(pk=self.user.userprofile.avatar_id) + self.assertEqual(fi.kind, 'avatar') + self.assertTrue(fi.stored_path.endswith('picture.jpeg')) + self.assertEqual(fi.content_type, 'image/jpeg') + self.assertEqual(fi.owner, self.user) + # The indexed file must be reachable through FileRequest. + url = reverse('library:file_request', args=[fi.uuid]) + self.client.get(url) # warm auth + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + self.assertEqual(b''.join(resp.streaming_content), _read_media(fi.stored_path)) + + +class ProfilePageTests(GatedTestCase): + def setUp(self): + User = get_user_model() + self.alice = User.objects.create_user(username='Alice', password='pw') + UserProfile.objects.get_or_create(user=self.alice) + self.bob = User.objects.create_user(username='Bob', password='pw') + UserProfile.objects.get_or_create(user=self.bob) + + def test_profile_page_renders(self): + self.gate() + resp = self.client.get(reverse('profiles:user_profile', args=['Alice'])) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Alice') + + def test_profile_unknown_user_404(self): + self.gate() + resp = self.client.get(reverse('profiles:user_profile', args=['Nobody'])) + self.assertEqual(resp.status_code, 404) + + def test_bio_owner_can_edit(self): + self.gate() + self.client.login(username='Alice', password='pw') + resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), { + 'action': 'bio', + 'bio': 'Hello there', + }) + self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice'])) + self.alice.userprofile.refresh_from_db() + self.assertEqual(self.alice.userprofile.bio, 'Hello there') + + def test_bio_non_owner_cannot_edit(self): + self.gate() + self.client.login(username='Bob', password='pw') + resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), { + 'action': 'bio', + 'bio': 'Hacked', + }) + # Non-owner must not be able to rewrite the bio. + self.alice.userprofile.refresh_from_db() + self.assertEqual(self.alice.userprofile.bio, '') + + def test_bio_over_max_length_rejected(self): + self.gate() + self.client.login(username='Alice', password='pw') + oversized = 'x' * (BIO_MAX_LENGTH + 1) + resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), { + 'action': 'bio', + 'bio': oversized, + }) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'at most') + self.alice.userprofile.refresh_from_db() + self.assertEqual(self.alice.userprofile.bio, '') + + def test_bio_at_max_length_accepted(self): + self.gate() + self.client.login(username='Alice', password='pw') + at_limit = 'y' * BIO_MAX_LENGTH + resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), { + 'action': 'bio', + 'bio': at_limit, + }) + self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice'])) + self.alice.userprofile.refresh_from_db() + self.assertEqual(len(self.alice.userprofile.bio), BIO_MAX_LENGTH) + + def test_bio_special_symbols_round_trip(self): + self.gate() + self.client.login(username='Alice', password='pw') + # Emoji, quotes, ampersands, HTML, unicode — must all round-trip. + fancy = 'Café &"\' 😀 line\nbreak\ttab 100% +plus?' + resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), { + 'action': 'bio', + 'bio': fancy, + }) + self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice'])) + self.alice.userprofile.refresh_from_db() + self.assertEqual(self.alice.userprofile.bio, fancy) + + +class FileRequestTests(MediaTestCase, GatedTestCase): + def setUp(self): + User = get_user_model() + self.alice = User.objects.create_user(username='Alice', password='pw') + UserProfile.objects.get_or_create(user=self.alice) + self.file_index = FileIndex.objects.create( + owner=self.alice, + kind='avatar', + stored_path='avatars/user_%d/picture.jpeg' % self.alice.pk, + original_filename='pic.jpeg', + content_type='image/jpeg', + size=4, + md5='abc', + ) + self.url = reverse('library:file_request', args=[self.file_index.uuid]) + + def _write_media(self): + from django.core.files.storage import default_storage + default_storage.save(self.file_index.stored_path, io.BytesIO(b'ABCD')) + + def test_full_request_200(self): + self._write_media() + self.gate() + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, 200) + self.assertEqual(b''.join(resp.streaming_content), b'ABCD') + self.assertEqual(resp['Accept-Ranges'], 'bytes') + + def test_byte_range_206(self): + self._write_media() + self.gate() + resp = self.client.get(self.url, HTTP_RANGE='bytes=0-1') + self.assertEqual(resp.status_code, 206) + self.assertEqual(b''.join(resp.streaming_content), b'AB') + self.assertEqual(resp['Content-Range'], 'bytes 0-1/4') + + def test_suffix_range_206(self): + self._write_media() + self.gate() + resp = self.client.get(self.url, HTTP_RANGE='bytes=-2') + self.assertEqual(resp.status_code, 206) + self.assertEqual(b''.join(resp.streaming_content), b'CD') + + def test_unsatisfiable_range_416(self): + self._write_media() + self.gate() + resp = self.client.get(self.url, HTTP_RANGE='bytes=99-100') + self.assertEqual(resp.status_code, 416) + self.assertEqual(resp['Content-Range'], 'bytes */4') + + def test_media_requires_gate(self): + self._write_media() + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, 401) + + def test_media_accessible_with_token(self): + self._write_media() + token = secrets.token_urlsafe(48) + ApiToken.objects.create( + user=self.alice, + token=token, + key_prefix=token[:8], + label='test', + ) + resp = self.client.get( + self.url, + HTTP_AUTHORIZATION=f'Bearer Alice {token}', + ) + self.assertEqual(resp.status_code, 200) + + def test_missing_file_404(self): + self.gate() + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, 404) diff --git a/nonpacks/profiles/urls.py b/nonpacks/profiles/urls.py index 7c47dee..0f136c1 100644 --- a/nonpacks/profiles/urls.py +++ b/nonpacks/profiles/urls.py @@ -9,6 +9,8 @@ urlpatterns = [ path('login/', views.user_login, name='login'), path('logout/', views.user_logout, name='logout'), path('settings/', views.settings_page, name='settings'), + path('settings/account/', views.account_page, name='account'), path('settings/tokens/create/', views.token_create, name='token_create'), path('settings/tokens//delete/', views.token_delete, name='token_delete'), + path('profiles//', views.user_profile, name='user_profile'), ] \ No newline at end of file diff --git a/nonpacks/profiles/views.py b/nonpacks/profiles/views.py index 195adf5..f2f49a1 100644 --- a/nonpacks/profiles/views.py +++ b/nonpacks/profiles/views.py @@ -1,11 +1,18 @@ +import hashlib +import io import secrets from django.contrib import messages -from django.contrib.auth import authenticate, login, logout +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, UserCreationForm -from django.shortcuts import redirect, render +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.shortcuts import get_object_or_404, redirect, render +from library.models import FileIndex + +from .forms import AvatarForm, BioForm, EmailForm, UsernameForm from .models import ApiToken, UserProfile @@ -37,7 +44,12 @@ def user_login(request): 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') @@ -67,4 +79,125 @@ def token_create(request): @login_required def token_delete(request, token_id): ApiToken.objects.filter(id=token_id, user=request.user).delete() - return redirect('profiles:settings') \ No newline at end of file + 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, + }) \ No newline at end of file diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index 53d3288..6bb141b 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -1758,3 +1758,105 @@ a.deletelink { border-radius: 4px; color: var(--md-sys-color-on-surface); } + +/* ========== Account Settings ========== */ +.account-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; + margin-top: 16px; +} +.account-card h2 { + font-size: 1.05rem; + margin-top: 0; + margin-bottom: 8px; +} +.account-current { + font-size: 0.85rem; + color: var(--md-sys-color-on-surface-variant); + margin-bottom: 12px; +} +.account-current code { + background: var(--md-sys-color-surface-variant); + padding: 2px 6px; + border-radius: 4px; +} +.avatar-preview { + margin-bottom: 12px; +} +.avatar-preview img { + width: 96px; + height: 96px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--md-sys-color-outline); +} +.avatar-preview .default-avatar { + width: 96px; + height: 96px; + border-radius: 50%; + font-size: 2.5rem; +} +.default-avatar.large { + width: 96px; + height: 96px; + font-size: 2.5rem; + border-radius: 50%; +} + +/* ========== Public Profile ========== */ +.profile-header { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 16px; +} +.profile-avatar img { + width: 120px; + height: 120px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--md-sys-color-outline); +} +.profile-avatar .default-avatar { + width: 120px; + height: 120px; + border-radius: 50%; + font-size: 3rem; +} +.profile-info h1 { + margin: 0 0 4px; +} +.profile-joined { + color: var(--md-sys-color-on-surface-variant); + font-size: 0.9rem; + margin: 0 0 12px; +} +.profile-section { + margin-bottom: 16px; +} +.profile-section h2 { + margin-top: 0; + font-size: 1.05rem; +} +.profile-bio { + white-space: pre-wrap; + line-height: 1.6; + margin: 0; +} +.profile-bio-empty { + color: var(--md-sys-color-on-surface-variant); + margin: 0; +} +.pack-card-empty { + opacity: 0.75; + pointer-events: none; +} +.pack-card-empty .pack-card-thumb { + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + color: var(--md-sys-color-on-surface-variant); + background: var(--md-sys-color-surface-variant); +} diff --git a/nonpacks/templates/base.html b/nonpacks/templates/base.html index c53a574..d008850 100644 --- a/nonpacks/templates/base.html +++ b/nonpacks/templates/base.html @@ -48,20 +48,21 @@
{% if user.userprofile.avatar %} - Avatar + Avatar {% else %}
{{ user.username|first|upper }}
{% endif %}
- {# TODO: wire edit profile page when it exists #} - + {{ user.username }} - {# TODO: wire upload / library pages when they exist #} + + Account Settings + - Settings + API Tokens
{% csrf_token %} diff --git a/nonpacks/templates/profiles/account.html b/nonpacks/templates/profiles/account.html new file mode 100644 index 0000000..ca6aa60 --- /dev/null +++ b/nonpacks/templates/profiles/account.html @@ -0,0 +1,86 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Account Settings - Packs{% endblock %} + +{% block content %} +

Account Settings

+

Manage API tokens

+ + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + +
+{% endblock %} diff --git a/nonpacks/templates/profiles/profile.html b/nonpacks/templates/profiles/profile.html new file mode 100644 index 0000000..27abe97 --- /dev/null +++ b/nonpacks/templates/profiles/profile.html @@ -0,0 +1,69 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ profile_user.username }} - Packs{% endblock %} + +{% block content %} +
+
+ {% if profile.avatar %} + {{ profile_user.username }} avatar + {% else %} +
{{ profile_user.username|first|upper }}
+ {% endif %} +
+
+

{{ profile_user.username }}

+

Joined {{ profile.joined|date:"F j, Y" }}

+ {% if is_owner %} +

Account Settings

+ {% endif %} +
+
+ +
+

Bio

+ {% if is_owner %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} +
+ {% csrf_token %} + +
+ {{ bio_form.bio.errors }} + {{ bio_form.bio }} +
+ +
+ {% else %} + {% if profile.bio %} +

{{ profile.bio }}

+ {% else %} +

{{ profile_user.username }} hasn't written a bio yet.

+ {% endif %} + {% endif %} +
+ +
+
+

Packs by {{ profile_user.username }}

+
+
+ {% comment %} Placeholder — replace with real Pack objects when the library model lands. {% endcomment %} +
+
+ +
+
+

No packs yet

+

This creator hasn't published any datapacks.

+
+
+
+
+{% endblock %} diff --git a/nonpacks/templates/profiles/settings.html b/nonpacks/templates/profiles/settings.html index cfedbfc..1b6492b 100644 --- a/nonpacks/templates/profiles/settings.html +++ b/nonpacks/templates/profiles/settings.html @@ -1,9 +1,10 @@ {% extends 'base.html' %} -{% block title %}Settings - Packs{% endblock %} +{% block title %}API Tokens - Packs{% endblock %} {% block content %} -

Settings

+

API Tokens

+

Account Settings

{% if messages %}
    @@ -13,7 +14,7 @@
{% endif %} -

API Tokens

+

Tokens

Tokens let non-browser clients (like a Fabric mod) access the API and download packs.

Send credentials as Authorization: Bearer <username> <token>,