Merge master (Sync-API) into worktree-tui-reader

This commit is contained in:
marwin 2026-08-15 16:22:54 +02:00
commit 3fcb74631c
10 changed files with 295 additions and 0 deletions

25
accounts/middleware.py Normal file
View file

@ -0,0 +1,25 @@
from .models import ApiToken
class ApiTokenAuthMiddleware:
"""Authenticates requests carrying `Authorization: Bearer <token>` 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)

View file

@ -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)),
],
),
]

View file

@ -1,3 +1,5 @@
import secrets
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models.signals import post_save 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): def save_user_profile(sender, instance, **kwargs):
if hasattr(instance, 'profile'): if hasattr(instance, 'profile'):
instance.profile.save() 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 <token>` 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})"

82
accounts/sync.py Normal file
View file

@ -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/<id>/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,
})

91
accounts/tests.py Normal file
View file

@ -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])

View file

@ -15,4 +15,5 @@ urlpatterns = [
path('focus-station/', views.save_focus_station, name='save_focus_station'), path('focus-station/', views.save_focus_station, name='save_focus_station'),
path('check-password/', views.check_password, name='check_password'), path('check-password/', views.check_password, name='check_password'),
path('change-password/', views.change_password, name='change_password'), path('change-password/', views.change_password, name='change_password'),
path('api-token/regenerate/', views.regenerate_api_token, name='regenerate_api_token'),
] ]

View file

@ -11,6 +11,8 @@ from django.views.decorators.http import require_http_methods
from radio import lastfm as lastfm_module from radio import lastfm as lastfm_module
from .models import ApiToken
User = get_user_model() User = get_user_model()
@ -72,6 +74,7 @@ def settings_view(request):
'profile': profile, 'profile': profile,
'has_lastfm': profile.has_lastfm(), 'has_lastfm': profile.has_lastfm(),
'password_form': PasswordChangeForm(request.user), 'password_form': PasswordChangeForm(request.user),
'api_token': getattr(request.user, 'api_token', None),
} }
return render(request, 'accounts/settings.html', context) return render(request, 'accounts/settings.html', context)
@ -209,6 +212,7 @@ def change_password(request):
'has_lastfm': profile.has_lastfm(), 'has_lastfm': profile.has_lastfm(),
'password_form': form, 'password_form': form,
'password_form_open': True, 'password_form_open': True,
'api_token': getattr(request.user, 'api_token', None),
}) })
@ -220,3 +224,15 @@ def lastfm_disconnect(request):
profile.lastfm_username = '' profile.lastfm_username = ''
profile.save(update_fields=['lastfm_session_key', 'lastfm_username']) profile.save(update_fields=['lastfm_session_key', 'lastfm_username'])
return redirect('settings') 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')

View file

@ -44,6 +44,7 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'accounts.middleware.ApiTokenAuthMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]

View file

@ -4,12 +4,15 @@ from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.views.static import serve as serve_static from django.views.static import serve as serve_static
from accounts.sync import sync_snapshot
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('accounts/', include('accounts.urls')), path('accounts/', include('accounts.urls')),
path('podcasts/', include('podcasts.urls')), path('podcasts/', include('podcasts.urls')),
path('books/', include('books.urls')), path('books/', include('books.urls')),
path('api/2/', include('gpodder.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 # 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 # 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 # directory unless the server sends Service-Worker-Allowed, so registering

View file

@ -99,6 +99,32 @@
<button type="submit" class="btn">Logout</button> <button type="submit" class="btn">Logout</button>
</form> </form>
</section> </section>
<!-- API token section -->
<section class="settings-section">
<h2>API-Zugriff für lokale Apps</h2>
<p class="lastfm-description">
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
<code>Authorization: Bearer &lt;token&gt;</code>-Header mitschicken, z. B. gegen
<code>GET /api/sync/</code> für einen kompletten Datenabzug.
</p>
{% if api_token %}
<p style="margin-top:0.75rem;">
<code style="user-select:all; word-break:break-all; background:#1a1a1a; padding:4px 8px; border-radius:4px; display:inline-block;">{{ api_token.token }}</code>
</p>
<form method="post" action="{% url 'regenerate_api_token' %}" class="inline-form" style="margin-top:0.75rem;"
onsubmit="return confirm('Neues Token generieren? Bereits verbundene Apps können sich dann nicht mehr anmelden.');">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Token neu generieren</button>
</form>
{% else %}
<form method="post" action="{% url 'regenerate_api_token' %}" class="inline-form" style="margin-top:0.75rem;">
{% csrf_token %}
<button type="submit" class="btn">Token generieren</button>
</form>
{% endif %}
</section>
</div> </div>
{% endblock %} {% endblock %}