Compare commits
2 commits
f63bd1f879
...
3fcb74631c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fcb74631c | ||
|
|
30a6d29ca8 |
10 changed files with 295 additions and 0 deletions
25
accounts/middleware.py
Normal file
25
accounts/middleware.py
Normal 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)
|
||||||
26
accounts/migrations/0004_apitoken.py
Normal file
26
accounts/migrations/0004_apitoken.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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
82
accounts/sync.py
Normal 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
91
accounts/tests.py
Normal 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])
|
||||||
|
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 <token></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 %}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue