Compare commits
No commits in common. "3fcb74631ca961a1dccbcf077481b5a22e36c2ab" and "f63bd1f879c7f2f135ddb67366bfa5c01ac42a3c" have entirely different histories.
3fcb74631c
...
f63bd1f879
10 changed files with 0 additions and 295 deletions
|
|
@ -1,25 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# 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,5 +1,3 @@
|
||||||
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
|
||||||
|
|
@ -35,25 +33,3 @@ 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})"
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
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,5 +15,4 @@ 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,8 +11,6 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -74,7 +72,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -212,7 +209,6 @@ 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),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -224,15 +220,3 @@ 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,7 +44,6 @@ 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,15 +4,12 @@ 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,32 +99,6 @@
|
||||||
<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