diff --git a/accounts/middleware.py b/accounts/middleware.py new file mode 100644 index 0000000..3ceb447 --- /dev/null +++ b/accounts/middleware.py @@ -0,0 +1,25 @@ +from .models import ApiToken + + +class ApiTokenAuthMiddleware: + """Authenticates requests carrying `Authorization: Bearer ` as the + owning user, for local/native clients that can't hold a Django session. + + Must run after AuthenticationMiddleware. Leaves request.user untouched + (AnonymousUser) on missing/invalid tokens — existing view-level auth + checks (`_require_auth`, `login_required`, `is_authenticated`) already + handle that case with a 401/redirect, so there's nothing to do here.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not request.user.is_authenticated: + auth = request.META.get('HTTP_AUTHORIZATION', '') + if auth.startswith('Bearer '): + token = auth[len('Bearer '):].strip() + if token: + api_token = ApiToken.objects.select_related('user').filter(token=token).first() + if api_token is not None: + request.user = api_token.user + return self.get_response(request) diff --git a/accounts/migrations/0004_apitoken.py b/accounts/migrations/0004_apitoken.py new file mode 100644 index 0000000..53a46f4 --- /dev/null +++ b/accounts/migrations/0004_apitoken.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.29 on 2026-08-15 14:01 + +import accounts.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('accounts', '0003_userprofile_background_encrypted_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ApiToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(db_index=True, default=accounts.models._generate_token, max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='api_token', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 92be437..616a150 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,3 +1,5 @@ +import secrets + from django.db import models from django.contrib.auth.models import User from django.db.models.signals import post_save @@ -33,3 +35,25 @@ def create_user_profile(sender, instance, created, **kwargs): def save_user_profile(sender, instance, **kwargs): if hasattr(instance, 'profile'): instance.profile.save() + + +def _generate_token(): + return secrets.token_hex(32) + + +class ApiToken(models.Model): + """Personal access token for local/native clients (see accounts.middleware). + + Sent as `Authorization: Bearer ` and treated identically to a + session login by ApiTokenAuthMiddleware — grants the same access as the + owning user across the whole app (books, podcasts, radio).""" + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='api_token') + token = models.CharField(max_length=64, unique=True, db_index=True, default=_generate_token) + created_at = models.DateTimeField(auto_now_add=True) + + def regenerate(self): + self.token = _generate_token() + self.save(update_fields=['token']) + + def __str__(self): + return f"ApiToken({self.user.username})" diff --git a/accounts/sync.py b/accounts/sync.py new file mode 100644 index 0000000..dcd2422 --- /dev/null +++ b/accounts/sync.py @@ -0,0 +1,82 @@ +from django.db.models import Max +from django.http import JsonResponse +from django.utils import timezone +from django.views.decorators.http import require_http_methods + +from books.models import EBook, EBookProgress, EBookHighlights, EBookBookmarks +from podcasts.models import PodcastFeed, EpisodeProgress, PodcastQueue +from radio.models import SavedStation + + +@require_http_methods(['GET']) +def sync_snapshot(request): + """Aggregate read-only snapshot of a user's data for local/native clients + (see accounts/middleware.py for the Bearer-token auth that makes this + reachable without a browser session). + + Deliberately excludes book file bytes and podcast audio — those stay + large/expensive and are fetched lazily via the existing per-resource + endpoints (e.g. GET /books//data/), which this same token also + unlocks. Writes (progress, highlights, bookmarks, ...) go through those + existing endpoints too, to reuse their merge semantics rather than + duplicating it here.""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + + user = request.user + + books = list(EBook.objects.filter(user=user).values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read')) + for b in books: + b['uploaded_at'] = b['uploaded_at'].isoformat() + + book_progress = list( + EBookProgress.objects.filter(user=user).values('book_id', 'scroll_fraction', 'position_anchor', 'updated_at') + ) + for p in book_progress: + p['updated_at'] = p['updated_at'].isoformat() + + book_highlights = list(EBookHighlights.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at')) + for h in book_highlights: + h['updated_at'] = h['updated_at'].isoformat() + + book_bookmarks = list(EBookBookmarks.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at')) + for bm in book_bookmarks: + bm['updated_at'] = bm['updated_at'].isoformat() + + podcast_feeds = list( + PodcastFeed.objects.filter(user=user) + .annotate(latest_episode_at=Max('episodes__pub_date')) + .values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at') + ) + for f in podcast_feeds: + for key in ('last_refreshed_at', 'added_at', 'latest_episode_at'): + if f[key]: + f[key] = f[key].isoformat() + + episode_progress = list( + EpisodeProgress.objects.filter(user=user) + .values('episode_id', 'position_seconds', 'played', 'dismissed', 'updated_at') + ) + for ep in episode_progress: + ep['updated_at'] = ep['updated_at'].isoformat() + + podcast_queue = list( + PodcastQueue.objects.filter(user=user).order_by('position').values('episode_id', 'position') + ) + + saved_stations = list( + SavedStation.objects.filter(user=user) + .values('id', 'name', 'url', 'bitrate', 'country', 'tags', 'favicon_url', 'is_favorite', 'notes') + ) + + return JsonResponse({ + 'server_time': timezone.now().isoformat(), + 'books': books, + 'book_progress': book_progress, + 'book_highlights': book_highlights, + 'book_bookmarks': book_bookmarks, + 'podcast_feeds': podcast_feeds, + 'episode_progress': episode_progress, + 'podcast_queue': podcast_queue, + 'saved_stations': saved_stations, + }) diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..49d830c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,91 @@ +import json + +from django.contrib.auth.models import User +from django.test import TestCase + +from .models import ApiToken +from books.models import EBook, EBookProgress, EBookHighlights + + +class ApiTokenAuthMiddlewareTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.token = ApiToken.objects.create(user=self.user) + + def test_valid_bearer_token_authenticates(self): + resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}') + self.assertEqual(resp.status_code, 200) + + def test_missing_token_is_unauthenticated(self): + resp = self.client.get('/books/') + self.assertEqual(resp.status_code, 401) + + def test_invalid_token_is_unauthenticated(self): + resp = self.client.get('/books/', HTTP_AUTHORIZATION='Bearer not-a-real-token') + self.assertEqual(resp.status_code, 401) + + def test_token_scoped_to_owning_user(self): + other = User.objects.create_user(username='bob', password='pw12345678') + EBook.objects.create(user=other, meta_ct='ct', meta_iv='iv', data_ct='ct', data_iv='iv') + resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}') + self.assertEqual(resp.json(), []) + + def test_regenerate_invalidates_old_token(self): + old = self.token.token + self.token.regenerate() + resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {old}') + self.assertEqual(resp.status_code, 401) + resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}') + self.assertEqual(resp.status_code, 200) + + def test_regenerate_view_requires_login_and_rotates_token(self): + resp = self.client.post('/accounts/api-token/regenerate/') + self.assertEqual(resp.status_code, 302) + self.assertIn('/accounts/login/', resp.url) + + self.client.login(username='alice', password='pw12345678') + old = self.token.token + resp = self.client.post('/accounts/api-token/regenerate/') + self.assertRedirects(resp, '/accounts/settings/') + self.token.refresh_from_db() + self.assertNotEqual(self.token.token, old) + + +class SyncSnapshotTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.token = ApiToken.objects.create(user=self.user) + self.book = EBook.objects.create( + user=self.user, meta_ct='meta-ct', meta_iv='meta-iv', data_ct='data-ct', data_iv='data-iv', + ) + EBookProgress.objects.create( + user=self.user, book=self.book, scroll_fraction=0.5, position_anchor='3:0.5', + ) + EBookHighlights.objects.create(user=self.user, book=self.book, ct='hl-ct', iv='hl-iv') + + def test_requires_auth(self): + resp = self.client.get('/api/sync/') + self.assertEqual(resp.status_code, 401) + + def test_snapshot_shape_and_data(self): + resp = self.client.get('/api/sync/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}') + self.assertEqual(resp.status_code, 200) + data = resp.json() + for key in ( + 'server_time', 'books', 'book_progress', 'book_highlights', 'book_bookmarks', + 'podcast_feeds', 'episode_progress', 'podcast_queue', 'saved_stations', + ): + self.assertIn(key, data) + + self.assertEqual(len(data['books']), 1) + self.assertEqual(data['books'][0]['meta_ct'], 'meta-ct') + + self.assertEqual(len(data['book_progress']), 1) + self.assertEqual(data['book_progress'][0]['book_id'], self.book.id) + self.assertEqual(data['book_progress'][0]['position_anchor'], '3:0.5') + + self.assertEqual(len(data['book_highlights']), 1) + self.assertEqual(data['book_highlights'][0]['ct'], 'hl-ct') + + # No data_ct/data_iv leaked into the snapshot (book bytes stay lazy-fetched) + self.assertNotIn('data_ct', data['books'][0]) diff --git a/accounts/urls.py b/accounts/urls.py index db38b26..82524c1 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -15,4 +15,5 @@ urlpatterns = [ path('focus-station/', views.save_focus_station, name='save_focus_station'), path('check-password/', views.check_password, name='check_password'), path('change-password/', views.change_password, name='change_password'), + path('api-token/regenerate/', views.regenerate_api_token, name='regenerate_api_token'), ] diff --git a/accounts/views.py b/accounts/views.py index 70b9920..0e71d7a 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -11,6 +11,8 @@ from django.views.decorators.http import require_http_methods from radio import lastfm as lastfm_module +from .models import ApiToken + User = get_user_model() @@ -72,6 +74,7 @@ def settings_view(request): 'profile': profile, 'has_lastfm': profile.has_lastfm(), 'password_form': PasswordChangeForm(request.user), + 'api_token': getattr(request.user, 'api_token', None), } return render(request, 'accounts/settings.html', context) @@ -209,6 +212,7 @@ def change_password(request): 'has_lastfm': profile.has_lastfm(), 'password_form': form, 'password_form_open': True, + 'api_token': getattr(request.user, 'api_token', None), }) @@ -220,3 +224,15 @@ def lastfm_disconnect(request): profile.lastfm_username = '' profile.save(update_fields=['lastfm_session_key', 'lastfm_username']) return redirect('settings') + + +# --------------------------------------------------------------------------- +# API token (for local/native clients — see accounts/middleware.py) +# --------------------------------------------------------------------------- + +@login_required +@require_http_methods(['POST']) +def regenerate_api_token(request): + token, _ = ApiToken.objects.get_or_create(user=request.user) + token.regenerate() + return redirect('settings') diff --git a/diora/settings.py b/diora/settings.py index c28d5f4..f1d0571 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -44,6 +44,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'accounts.middleware.ApiTokenAuthMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] diff --git a/diora/urls.py b/diora/urls.py index 234e995..0664906 100644 --- a/diora/urls.py +++ b/diora/urls.py @@ -4,12 +4,15 @@ from django.contrib import admin from django.urls import path, include from django.views.static import serve as serve_static +from accounts.sync import sync_snapshot + urlpatterns = [ path('admin/', admin.site.urls), path('accounts/', include('accounts.urls')), path('podcasts/', include('podcasts.urls')), path('books/', include('books.urls')), path('api/2/', include('gpodder.urls')), + path('api/sync/', sync_snapshot, name='api_sync'), # Served at the root (not /static/js/sw.js) so its default scope covers # the whole app — a service worker's scope is limited to its own script's # directory unless the server sends Service-Worker-Allowed, so registering diff --git a/templates/accounts/settings.html b/templates/accounts/settings.html index b0f7510..2fffad2 100644 --- a/templates/accounts/settings.html +++ b/templates/accounts/settings.html @@ -99,6 +99,32 @@ + + +
+

API-Zugriff für lokale Apps

+

+ Mit einem persönlichen Token können eigene Apps auf deine diora-Daten zugreifen (Bücher, + Lesefortschritt, Notizen, Podcasts, Radio) — ohne dein Passwort zu teilen. Token als + Authorization: Bearer <token>-Header mitschicken, z. B. gegen + GET /api/sync/ für einen kompletten Datenabzug. +

+ {% if api_token %} +

+ {{ api_token.token }} +

+
+ {% csrf_token %} + +
+ {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
{% endblock %}