Compare commits

..

No commits in common. "b205625e21d4f40922c661a747dbe50e3a9b2013" and "7392bbcdccfd3b17441ac8d1f5864ca39c64997d" have entirely different histories.

39 changed files with 431 additions and 5656 deletions

View file

@ -3,8 +3,3 @@ DEBUG=True
AMAZON_AFFILIATE_TAG=diora-20
LASTFM_API_KEY=
LASTFM_API_SECRET=
# Cloud import (WebDAV/Nextcloud): allow connections to LAN/loopback addresses.
# Leave False on any instance with open registration — it is what stops a user
# from probing the internal network through the import proxy.
WEBDAV_ALLOW_PRIVATE_HOSTS=False

11
.gitignore vendored
View file

@ -33,14 +33,3 @@ CLAUDE.md
# OS
.DS_Store
Thumbs.db
antennapod-feeds-2026-03-19.opml
.gitignore
playlist.m3u
# Playwright / Node
node_modules/
test-results/
playwright-report/
blob-report/
e2e/.auth/
data/e2e_test.sqlite3

View file

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

View file

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

View file

@ -1,33 +0,0 @@
# Generated by Django 6.1 on 2026-08-28 08:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_apitoken'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WebDAVSource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.CharField(max_length=100)),
('base_url', models.URLField(max_length=500)),
('username', models.CharField(blank=True, max_length=200)),
('password', models.CharField(blank=True, max_length=500)),
('root_path', models.CharField(blank=True, default='', max_length=500)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webdav_sources', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
]

View file

@ -1,6 +1,3 @@
import secrets
from urllib.parse import urlsplit
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
@ -36,76 +33,3 @@ def create_user_profile(sender, instance, created, **kwargs):
def save_user_profile(sender, instance, **kwargs):
if hasattr(instance, 'profile'):
instance.profile.save()
class WebDAVSource(models.Model):
"""A user-supplied WebDAV endpoint that ebooks can be imported from.
Deliberately generic rather than Nextcloud-specific any WebDAV server
(Nextcloud, ownCloud, Synology, rclone serve, ) works, Nextcloud just gets
a URL-shorthand in `normalized_base_url`.
The password is stored in the clear because the server has to replay it on
every PROPFIND/GET (same trade-off as `lastfm_session_key` above). The UI
therefore tells users to create a revocable *app password* rather than
handing over their account password.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='webdav_sources')
label = models.CharField(max_length=100)
base_url = models.URLField(max_length=500)
username = models.CharField(max_length=200, blank=True)
password = models.CharField(max_length=500, blank=True)
root_path = models.CharField(max_length=500, blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"WebDAVSource({self.label}, user={self.user_id})"
def normalized_base_url(self) -> str:
"""Collection root to resolve browse paths against, always ending in '/'.
A bare host ('https://cloud.example.com') is expanded to the Nextcloud
files endpoint, since that is the URL users actually have at hand; an
URL that already points into a DAV tree is left alone so non-Nextcloud
servers stay usable.
"""
url = self.base_url.strip()
if not url.endswith('/'):
url += '/'
# Match against the path only — a host literally named "webdav.…" or
# "dav.…" must not be mistaken for a URL that already points into a
# DAV tree.
path = urlsplit(url).path.lower()
is_dav = any(marker in path for marker in ('/remote.php/', '/dav/', '/webdav'))
if not is_dav and self.username:
url += f'remote.php/dav/files/{self.username}/'
root = self.root_path.strip().strip('/')
if root:
url += root + '/'
return url
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})"

View file

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

View file

@ -1,528 +0,0 @@
import json
import socket
from types import SimpleNamespace
from unittest.mock import patch
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from .models import ApiToken, WebDAVSource
from .webdav import WebDAVError, assert_safe_url, fetch_file, list_directory, safe_rel_path
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])
# ---------------------------------------------------------------------------
# WebDAV cloud import
# ---------------------------------------------------------------------------
PROPFIND_RESPONSE = b'''<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/alice/Books/</d:href>
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/alice/Books/Sci-Fi/</d:href>
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/alice/Books/Der%20Steppenwolf.epub</d:href>
<d:propstat><d:prop><d:resourcetype/><d:getcontentlength>4096</d:getcontentlength></d:prop></d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/alice/Books/notes.txt</d:href>
<d:propstat><d:prop><d:resourcetype/><d:getcontentlength>12</d:getcontentlength></d:prop></d:propstat>
</d:response>
</d:multistatus>'''
class _FakeSocket:
def __init__(self, peer):
self._peer = peer
def getpeername(self):
return (self._peer, 443)
class _FakeResponse:
def __init__(self, status_code=207, content=b'', headers=None, peer=None):
self.status_code = status_code
self.content = content
self.headers = headers or {}
self.closed = False
# Mirrors requests' response.raw._connection.sock, which is what
# _assert_peer_is_safe introspects. None means "nothing to check".
if peer is None:
self.raw = None
else:
self.raw = SimpleNamespace(_connection=SimpleNamespace(sock=_FakeSocket(peer)))
def iter_content(self, chunk_size=None):
yield self.content
def close(self):
self.closed = True
class NormalizedBaseUrlTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
def _source(self, **kwargs):
kwargs.setdefault('label', 'cloud')
kwargs.setdefault('username', 'alice')
return WebDAVSource(user=self.user, **kwargs)
def test_bare_host_expands_to_nextcloud_files_endpoint(self):
source = self._source(base_url='https://cloud.example.com')
self.assertEqual(
source.normalized_base_url(),
'https://cloud.example.com/remote.php/dav/files/alice/',
)
def test_root_path_is_appended(self):
source = self._source(base_url='https://cloud.example.com', root_path='/Buecher/')
self.assertEqual(
source.normalized_base_url(),
'https://cloud.example.com/remote.php/dav/files/alice/Buecher/',
)
def test_explicit_dav_url_is_left_alone(self):
source = self._source(base_url='https://dav.example.com/webdav')
self.assertEqual(source.normalized_base_url(), 'https://dav.example.com/webdav/')
def test_generic_server_without_username_is_not_rewritten(self):
source = self._source(base_url='https://files.example.com/share', username='')
self.assertEqual(source.normalized_base_url(), 'https://files.example.com/share/')
class SafeRelPathTests(TestCase):
def test_traversal_is_rejected(self):
with self.assertRaises(WebDAVError):
safe_rel_path('Books/../../etc/passwd')
def test_leading_slashes_and_dots_are_stripped(self):
self.assertEqual(safe_rel_path('/Books/./Sci-Fi/'), 'Books/Sci-Fi')
def test_empty_path_is_root(self):
self.assertEqual(safe_rel_path(''), '')
self.assertEqual(safe_rel_path('/'), '')
class AssertSafeUrlTests(TestCase):
def _resolve_to(self, ip):
return [(2, 1, 6, '', (ip, 443))]
def test_non_http_scheme_rejected(self):
with self.assertRaises(WebDAVError):
assert_safe_url('file:///etc/passwd')
def test_private_address_rejected(self):
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('172.18.0.4')):
with self.assertRaises(WebDAVError):
assert_safe_url('https://internal.example.com/dav/')
def test_loopback_rejected(self):
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('127.0.0.1')):
with self.assertRaises(WebDAVError):
assert_safe_url('http://localhost:11000/remote.php/dav/')
def test_link_local_metadata_endpoint_rejected(self):
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('169.254.169.254')):
with self.assertRaises(WebDAVError):
assert_safe_url('http://metadata.example.com/')
def test_public_address_allowed(self):
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('85.214.6.118')):
assert_safe_url('https://nc.example.com/remote.php/dav/')
def test_unresolvable_host_rejected(self):
with patch('accounts.webdav.socket.getaddrinfo', side_effect=socket.gaierror):
with self.assertRaises(WebDAVError):
assert_safe_url('https://nope.example.com/')
@override_settings(WEBDAV_ALLOW_PRIVATE_HOSTS=True)
def test_private_allowed_when_opted_in(self):
assert_safe_url('http://192.168.1.10/dav/')
class ListDirectoryTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.source = WebDAVSource.objects.create(
user=self.user, label='cloud', base_url='https://cloud.example.com',
username='alice', password='app-pw', root_path='Books',
)
def _list(self, response=None):
with patch('accounts.webdav.assert_safe_url'), \
patch('accounts.webdav.requests.request',
return_value=response or _FakeResponse(content=PROPFIND_RESPONSE)):
return list_directory(self.source)
def test_entries_are_parsed_and_sorted_dirs_first(self):
entries = self._list()
self.assertEqual([e['name'] for e in entries],
['Sci-Fi', 'Der Steppenwolf.epub', 'notes.txt'])
def test_collection_itself_is_excluded(self):
self.assertNotIn('Books', [e['name'] for e in self._list()])
def test_book_flag_and_size(self):
by_name = {e['name']: e for e in self._list()}
self.assertTrue(by_name['Der Steppenwolf.epub']['is_book'])
self.assertEqual(by_name['Der Steppenwolf.epub']['size'], 4096)
self.assertFalse(by_name['notes.txt']['is_book'])
self.assertTrue(by_name['Sci-Fi']['is_dir'])
def test_redirect_is_refused_rather_than_followed(self):
redirect_response = _FakeResponse(status_code=302, headers={'Location': 'http://127.0.0.1/'})
with self.assertRaises(WebDAVError):
self._list(redirect_response)
def test_bad_credentials_surface_clearly(self):
with self.assertRaises(WebDAVError) as ctx:
self._list(_FakeResponse(status_code=401))
self.assertIn('App-Passwort', str(ctx.exception))
class FetchFileTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.source = WebDAVSource.objects.create(
user=self.user, label='cloud', base_url='https://cloud.example.com', username='alice',
)
def test_non_book_extension_refused_before_any_request(self):
with patch('accounts.webdav.requests.request') as mock_request:
with self.assertRaises(WebDAVError):
fetch_file(self.source, 'secrets.env', 1024)
mock_request.assert_not_called()
def test_declared_oversize_refused(self):
response = _FakeResponse(status_code=200, headers={'Content-Length': '99999'})
with patch('accounts.webdav.assert_safe_url'), \
patch('accounts.webdav.requests.request', return_value=response):
with self.assertRaises(WebDAVError):
fetch_file(self.source, 'big.epub', 1024)
def test_streamed_oversize_refused_even_without_content_length(self):
response = _FakeResponse(status_code=200, content=b'x' * 5000)
with patch('accounts.webdav.assert_safe_url'), \
patch('accounts.webdav.requests.request', return_value=response):
with self.assertRaises(WebDAVError):
fetch_file(self.source, 'sneaky.epub', 1024)
def test_successful_fetch_returns_bytes(self):
response = _FakeResponse(status_code=200, content=b'EPUB-BYTES')
with patch('accounts.webdav.assert_safe_url'), \
patch('accounts.webdav.requests.request', return_value=response):
self.assertEqual(fetch_file(self.source, 'ok.epub', 1024), b'EPUB-BYTES')
class CloudImportViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.other = User.objects.create_user(username='bob', password='pw12345678')
self.source = WebDAVSource.objects.create(
user=self.user, label='cloud', base_url='https://cloud.example.com', username='alice',
)
self.foreign = WebDAVSource.objects.create(
user=self.other, label='bobs', base_url='https://other.example.com', username='bob',
)
def test_endpoints_require_authentication(self):
self.assertEqual(self.client.get('/books/cloud/sources/').status_code, 401)
self.assertEqual(self.client.get(f'/books/cloud/{self.source.pk}/browse/').status_code, 401)
self.assertEqual(self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=a.epub').status_code, 401)
def test_sources_are_scoped_to_the_owner(self):
self.client.force_login(self.user)
labels = [s['label'] for s in self.client.get('/books/cloud/sources/').json()['sources']]
self.assertEqual(labels, ['cloud'])
def test_foreign_source_is_not_browsable(self):
self.client.force_login(self.user)
resp = self.client.get(f'/books/cloud/{self.foreign.pk}/browse/')
self.assertEqual(resp.status_code, 404)
def test_browse_returns_entries(self):
self.client.force_login(self.user)
entries = [{'name': 'Dune.epub', 'path': 'Dune.epub', 'is_dir': False,
'size': 10, 'modified': '', 'is_book': True}]
with patch('books.webdav.list_directory', return_value=entries):
resp = self.client.get(f'/books/cloud/{self.source.pk}/browse/')
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['entries'], entries)
def test_browse_reports_upstream_failure_as_502(self):
self.client.force_login(self.user)
with patch('books.webdav.list_directory', side_effect=WebDAVError('kaputt')):
resp = self.client.get(f'/books/cloud/{self.source.pk}/browse/')
self.assertEqual(resp.status_code, 502)
self.assertEqual(resp.json()['error'], 'kaputt')
def test_bad_user_input_is_400_not_502(self):
self.client.force_login(self.user)
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=../../etc/passwd')
self.assertEqual(resp.status_code, 400)
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=notes.txt')
self.assertEqual(resp.status_code, 400)
def test_fetch_streams_bytes_without_storing_them(self):
self.client.force_login(self.user)
with patch('books.webdav.fetch_file', return_value=b'EPUB-BYTES'):
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=Dune.epub')
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, b'EPUB-BYTES')
# The proxy is a pass-through: nothing is persisted server-side.
self.assertEqual(EBook.objects.count(), 0)
BILLION_LAUGHS = b'''<?xml version="1.0"?>
<!DOCTYPE lolz [
<!ENTITY lol "lol">
<!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
]>
<d:multistatus xmlns:d="DAV:"><d:response><d:href>&lol4;</d:href></d:response></d:multistatus>'''
# A server that answers with the 404 propstat first — legal per RFC 4918, and
# what made directories disappear from the listing before _select_prop existed.
PROPSTAT_404_FIRST = b'''<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/remote.php/dav/files/alice/Books/</d:href>
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/alice/Books/Sci-Fi/</d:href>
<d:propstat><d:prop><d:getcontentlength/></d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response>
</d:multistatus>'''
RELATIVE_HREF_RESPONSE = b'''<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response><d:href>Dune.epub</d:href>
<d:propstat><d:prop><d:resourcetype/><d:getcontentlength>7</d:getcontentlength></d:prop>
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response>
</d:multistatus>'''
class WebDAVHardeningTests(TestCase):
"""Regressions for the SSRF / resource-exhaustion review findings."""
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.source = WebDAVSource.objects.create(
user=self.user, label='cloud', base_url='https://cloud.example.com',
username='alice', password='app-pw', root_path='Books',
)
def _list(self, response):
with patch('accounts.webdav.assert_safe_url'), \
patch('accounts.webdav.requests.request', return_value=response):
return list_directory(self.source)
# --- DNS rebinding -----------------------------------------------------
def test_peer_address_is_rechecked_after_connecting(self):
"""A resolver that answers public-then-private must not leak a body.
assert_safe_url passes (it is given a public answer), but the socket
actually landed on a Docker-internal address.
"""
response = _FakeResponse(content=PROPFIND_RESPONSE, peer='172.18.0.5')
with self.assertRaises(WebDAVError):
self._list(response)
self.assertTrue(response.closed)
def test_public_peer_is_accepted(self):
response = _FakeResponse(content=PROPFIND_RESPONSE, peer='85.214.6.118')
self.assertTrue(self._list(response))
@override_settings(WEBDAV_ALLOW_PRIVATE_HOSTS=True)
def test_peer_check_respects_the_opt_out(self):
response = _FakeResponse(content=PROPFIND_RESPONSE, peer='192.168.1.10')
self.assertTrue(self._list(response))
# --- Resource exhaustion ----------------------------------------------
def test_entity_expansion_is_refused(self):
with self.assertRaises(WebDAVError):
self._list(_FakeResponse(content=BILLION_LAUGHS))
def test_oversized_listing_is_refused(self):
oversized = _FakeResponse(headers={'Content-Length': str(9 * 1024 * 1024)})
with self.assertRaises(WebDAVError):
self._list(oversized)
self.assertTrue(oversized.closed)
# --- Information disclosure -------------------------------------------
def test_rejection_message_leaks_neither_ip_nor_resolvability(self):
private = [(2, 1, 6, '', ('172.18.0.5', 443))]
with patch('accounts.webdav.socket.getaddrinfo', return_value=private):
with self.assertRaises(WebDAVError) as private_ctx:
assert_safe_url('http://forgejo-db/')
with patch('accounts.webdav.socket.getaddrinfo', side_effect=socket.gaierror):
with self.assertRaises(WebDAVError) as missing_ctx:
assert_safe_url('http://forgejo-db/')
self.assertNotIn('172.18.0.5', str(private_ctx.exception))
# Same wording either way, so the endpoint cannot be used to tell an
# existing internal host from a nonexistent one.
self.assertEqual(str(private_ctx.exception), str(missing_ctx.exception))
# --- Malformed input ---------------------------------------------------
def test_invalid_port_is_reported_not_crashed(self):
with self.assertRaises(WebDAVError):
assert_safe_url('https://example.com:99999/dav/')
def test_ipv6_transition_ranges_are_rejected(self):
for address in ('64:ff9b::7f00:1', '::127.0.0.1'):
with patch('accounts.webdav.socket.getaddrinfo',
return_value=[(10, 1, 6, '', (address, 443, 0, 0))]):
with self.assertRaises(WebDAVError, msg=address):
assert_safe_url('https://nat64.example.com/')
# --- PROPFIND parsing --------------------------------------------------
def test_directory_survives_a_404_propstat_listed_first(self):
entries = self._list(_FakeResponse(content=PROPSTAT_404_FIRST))
by_name = {e['name']: e for e in entries}
self.assertTrue(by_name['Sci-Fi']['is_dir'])
def test_relative_hrefs_are_resolved(self):
entries = self._list(_FakeResponse(content=RELATIVE_HREF_RESPONSE))
self.assertEqual([e['name'] for e in entries], ['Dune.epub'])
# --- URL normalisation -------------------------------------------------
def test_host_named_webdav_still_gets_the_nextcloud_path(self):
source = WebDAVSource(user=self.user, label='x',
base_url='https://webdav.example.com', username='alice')
self.assertEqual(
source.normalized_base_url(),
'https://webdav.example.com/remote.php/dav/files/alice/',
)
class WebDAVSourceLimitTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
@override_settings(WEBDAV_MAX_SOURCES_PER_USER=2)
def test_sources_are_capped_per_user(self):
with patch('accounts.views._probe_source', return_value=(20, 'ok')):
for i in range(3):
self.client.post('/accounts/webdav/add/',
{'label': f'c{i}', 'base_url': 'https://example.com'})
self.assertEqual(self.user.webdav_sources.count(), 2)
class CloudFetchCachingTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.source = WebDAVSource.objects.create(
user=self.user, label='cloud', base_url='https://cloud.example.com', username='alice',
)
def test_plaintext_bytes_are_not_cacheable(self):
self.client.force_login(self.user)
with patch('books.webdav.fetch_file', return_value=b'EPUB-BYTES'):
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=Dune.epub')
self.assertEqual(resp['Cache-Control'], 'no-store')

View file

@ -13,10 +13,4 @@ urlpatterns = [
path('background/upload/', views.upload_background, name='upload_background'),
path('background/delete/', views.delete_background, name='delete_background'),
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'),
path('webdav/add/', views.webdav_add, name='webdav_add'),
path('webdav/<int:pk>/test/', views.webdav_test, name='webdav_test'),
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
]

View file

@ -1,10 +1,9 @@
import base64
import json
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import authenticate, login, get_user_model, update_session_auth_hash
from django.contrib.auth import authenticate, login, get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, PasswordChangeForm
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
from django.http import JsonResponse
from django.shortcuts import render, redirect
from django.views.decorators.csrf import csrf_exempt
@ -12,9 +11,6 @@ from django.views.decorators.http import require_http_methods
from radio import lastfm as lastfm_module
from .models import ApiToken, WebDAVSource
from .webdav import WebDAVError, list_directory
User = get_user_model()
@ -75,9 +71,6 @@ def settings_view(request):
context = {
'profile': profile,
'has_lastfm': profile.has_lastfm(),
'password_form': PasswordChangeForm(request.user),
'api_token': getattr(request.user, 'api_token', None),
'webdav_sources': request.user.webdav_sources.all(),
}
return render(request, 'accounts/settings.html', context)
@ -188,38 +181,6 @@ def save_focus_station(request):
return JsonResponse({'ok': True})
@login_required
@csrf_exempt
@require_http_methods(['POST'])
def check_password(request):
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'ok': False}, status=400)
pw = body.get('password', '')
user = authenticate(request, username=request.user.username, password=pw)
return JsonResponse({'ok': user is not None})
@login_required
@require_http_methods(['POST'])
def change_password(request):
form = PasswordChangeForm(request.user, request.POST)
if form.is_valid():
user = form.save()
update_session_auth_hash(request, user)
return redirect('settings')
profile = request.user.profile
return render(request, 'accounts/settings.html', {
'profile': profile,
'has_lastfm': profile.has_lastfm(),
'password_form': form,
'password_form_open': True,
'api_token': getattr(request.user, 'api_token', None),
'webdav_sources': request.user.webdav_sources.all(),
})
@login_required
@require_http_methods(['POST'])
def lastfm_disconnect(request):
@ -228,85 +189,3 @@ 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')
# ---------------------------------------------------------------------------
# WebDAV sources (Nextcloud & friends) — used by the ebook cloud import
# ---------------------------------------------------------------------------
def _probe_source(source):
"""Report a source's reachability as a (level, message) pair for messages."""
try:
entries = list_directory(source)
except WebDAVError as exc:
return messages.WARNING, f'{source.label}“ gespeichert, aber nicht erreichbar: {exc}'
books = sum(1 for e in entries if e['is_book'])
folders = sum(1 for e in entries if e['is_dir'])
return messages.SUCCESS, (
f'{source.label}“ verbunden — {books} Buch/Bücher und {folders} Ordner im Startverzeichnis.'
)
@login_required
@require_http_methods(['POST'])
def webdav_add(request):
label = request.POST.get('label', '').strip()
base_url = request.POST.get('base_url', '').strip()
if not label or not base_url:
messages.error(request, 'Name und Server-URL sind erforderlich.')
return redirect('settings')
# Each source costs a synchronous probe on add and is reachable from the
# import endpoints, so cap how many one account can pile up.
max_sources = getattr(settings, 'WEBDAV_MAX_SOURCES_PER_USER', 10)
if request.user.webdav_sources.count() >= max_sources:
messages.error(request, f'Maximal {max_sources} Cloud-Verbindungen pro Konto.')
return redirect('settings')
source = WebDAVSource.objects.create(
user=request.user,
label=label[:100],
base_url=base_url[:500],
username=request.POST.get('username', '').strip()[:200],
password=request.POST.get('password', '')[:500],
root_path=request.POST.get('root_path', '').strip()[:500],
)
level, message = _probe_source(source)
messages.add_message(request, level, message)
return redirect('settings')
@login_required
@require_http_methods(['POST'])
def webdav_test(request, pk):
source = WebDAVSource.objects.filter(pk=pk, user=request.user).first()
if not source:
messages.error(request, 'Verbindung nicht gefunden.')
return redirect('settings')
level, message = _probe_source(source)
messages.add_message(request, level, message)
return redirect('settings')
@login_required
@require_http_methods(['POST'])
def webdav_delete(request, pk):
deleted, _ = WebDAVSource.objects.filter(pk=pk, user=request.user).delete()
if deleted:
messages.success(request, 'Verbindung entfernt.')
return redirect('settings')

View file

@ -1,380 +0,0 @@
"""Minimal, hardened WebDAV client behind the ebook cloud import.
Only PROPFIND (Depth 1) and GET are implemented enough to browse a remote
folder and pull a single file out of it.
Every request here is made by the server to a URL the *user* supplied, which
makes this an SSRF surface: registration is open (accounts/views.py:23) and
diora normally runs in a Docker network next to other services. Three things
guard it, and all three are load-bearing:
* `assert_safe_url` refuses hosts that resolve to non-public addresses,
* `_assert_peer_is_safe` re-checks the address we *actually* connected to,
because requests resolves the hostname a second time and a hostile resolver
can answer differently across the two lookups (DNS rebinding),
* redirects are refused rather than followed, so a public host cannot bounce
us onto a private one.
Residual risk worth knowing about: a rebinding attacker can still cause a
single request to be *sent* to an internal address; the peer check runs before
the body is read, so nothing comes back to them. Fully closing that needs
connect-time DNS pinning, which requests does not expose without reaching into
urllib3.
Response bodies are read through `_read_capped` and parsed only after any DTD
is refused the remote server is attacker-chosen, so an unbounded read or a
billion-laughs document would otherwise be a cheap way to OOM the container.
"""
import ipaddress
import socket
import xml.etree.ElementTree as ET
from urllib.parse import quote, unquote, urljoin, urlsplit
import requests
from django.conf import settings
DAV_NS = '{DAV:}'
BOOK_EXTENSIONS = ('.epub', '.pdf')
# A directory listing is XML; anything this large is not a real one.
MAX_LISTING_BYTES = 8 * 1024 * 1024
class WebDAVError(Exception):
"""Failure with a message that is safe to show the user directly."""
class WebDAVInputError(WebDAVError):
"""The user's own URL/path/credentials are at fault, not the remote server.
Separated so views can answer 400 instead of 502 a rejected traversal
attempt is not an upstream outage.
"""
def _timeout():
return getattr(settings, 'WEBDAV_TIMEOUT', 15)
def _allow_private():
return getattr(settings, 'WEBDAV_ALLOW_PRIVATE_HOSTS', False)
# ---------------------------------------------------------------------------
# SSRF guard
# ---------------------------------------------------------------------------
def _unreachable(host):
"""One message for every "we won't talk to this" case.
Deliberately does not say whether the name failed to resolve or resolved to
a private address, and never echoes the resolved IP: telling those apart
would turn this endpoint into a scanner for the Docker network's service
names and addresses.
"""
return WebDAVInputError(
f'{host}“ ist nicht öffentlich erreichbar. Aus Sicherheitsgründen sind nur '
'öffentlich auflösbare Server erlaubt.'
)
def _is_public(raw_address):
"""True only for addresses we are willing to open a connection to."""
try:
# Scope IDs ('fe80::1%eth0') are not part of the address itself.
ip = ipaddress.ip_address(raw_address.split('%')[0])
except ValueError:
return False
if not ip.is_global:
return False
# is_global misses two IPv6 transition ranges that can carry an embedded
# private IPv4 address: the well-known NAT64 prefix (RFC 6052) and the
# deprecated IPv4-compatible range.
if ip.version == 6:
for unsafe in ('64:ff9b::/96', '::/96'):
if ip in ipaddress.ip_network(unsafe):
return False
return True
def assert_safe_url(url):
"""Reject URLs that don't resolve to a public address.
Self-hosted setups that legitimately want a LAN target can opt out with
WEBDAV_ALLOW_PRIVATE_HOSTS=True, which is why this is a setting and not a
hard rule.
"""
parts = urlsplit(url)
if parts.scheme not in ('http', 'https'):
raise WebDAVInputError('Nur http:// und https:// werden unterstützt.')
host = parts.hostname
if not host:
raise WebDAVInputError('Die URL enthält keinen Hostnamen.')
try:
port = parts.port or (443 if parts.scheme == 'https' else 80)
except ValueError:
# urlsplit validates the port lazily, on attribute access.
raise WebDAVInputError('Die URL enthält einen ungültigen Port.')
if _allow_private():
return
try:
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
except socket.gaierror:
raise _unreachable(host)
for info in infos:
if not _is_public(info[4][0]):
raise _unreachable(host)
def _assert_peer_is_safe(response):
"""Re-check the address the socket actually connected to.
`assert_safe_url` validates DNS before the request, but requests resolves
the name again when it opens the connection. A resolver answering
public-then-private across those two lookups would otherwise hand us an
internal service's response body.
"""
if _allow_private():
return
sock = getattr(getattr(response.raw, '_connection', None), 'sock', None)
if sock is None:
return # nothing to introspect (mocked, or already released)
try:
peer = sock.getpeername()[0]
except OSError:
return
if not _is_public(peer):
raise WebDAVInputError('Der Server ist nicht öffentlich erreichbar.')
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
def safe_rel_path(raw):
"""Normalise a client-supplied path and keep it inside the source root.
Whatever survives here is percent-encoded by `_build_url` with `quote()`'s
default `safe='/'`, and that is what stops double-encoded traversal
('%252e%252e%252f') and smuggled absolute URLs: '%' and ':' both get
escaped, so they land as literal filename characters. Widening that safe
set would reopen those paths.
"""
raw = (raw or '').strip().strip('/')
if not raw:
return ''
segments = []
for segment in raw.split('/'):
if not segment or segment == '.':
continue
if segment == '..':
raise WebDAVInputError('Ungültiger Pfad.')
segments.append(segment)
return '/'.join(segments)
def _build_url(source, rel_path):
base = source.normalized_base_url()
if not rel_path:
return base
return base + quote(rel_path)
def _request(method, url, source, **kwargs):
assert_safe_url(url)
auth = (source.username, source.password) if source.username else None
try:
# Always streamed: it keeps the connection open long enough to inspect
# the peer, and stops requests from buffering an unbounded body before
# we get the chance to cap it.
response = requests.request(
method, url, auth=auth, timeout=_timeout(),
allow_redirects=False, stream=True, **kwargs
)
except requests.Timeout:
raise WebDAVError('Zeitüberschreitung beim Server.')
except requests.RequestException as exc:
raise WebDAVError(f'Verbindung fehlgeschlagen: {exc.__class__.__name__}')
try:
_assert_peer_is_safe(response)
if response.status_code in (301, 302, 303, 307, 308):
raise WebDAVError(
'Der Server hat eine Weiterleitung geschickt. '
'Bitte trage die Ziel-URL direkt ein.'
)
if response.status_code in (401, 403):
raise WebDAVInputError('Anmeldung abgelehnt — Benutzername oder App-Passwort prüfen.')
if response.status_code == 404:
raise WebDAVError('Pfad auf dem Server nicht gefunden.')
if response.status_code >= 400:
raise WebDAVError(f'Server antwortete mit HTTP {response.status_code}.')
except Exception:
response.close()
raise
return response
def _read_capped(response, max_bytes, oversize_error):
"""Read a streamed body, aborting as soon as it exceeds `max_bytes`."""
declared = response.headers.get('Content-Length')
if declared and declared.isdigit() and int(declared) > max_bytes:
response.close()
raise oversize_error()
chunks = []
total = 0
try:
for chunk in response.iter_content(chunk_size=64 * 1024):
total += len(chunk)
if total > max_bytes:
raise oversize_error()
chunks.append(chunk)
except requests.RequestException:
raise WebDAVError('Übertragung abgebrochen.')
finally:
response.close()
return b''.join(chunks)
# ---------------------------------------------------------------------------
# PROPFIND / GET
# ---------------------------------------------------------------------------
_PROPFIND_BODY = (
'<?xml version="1.0" encoding="utf-8"?>'
'<d:propfind xmlns:d="DAV:"><d:prop>'
'<d:resourcetype/><d:getcontentlength/><d:getlastmodified/>'
'</d:prop></d:propfind>'
)
def _parse_listing_xml(payload):
"""Parse a multistatus body, refusing any document that carries a DTD.
The remote server is chosen by the user, so a billion-laughs document is
within an attacker's reach, and ElementTree *does* expand internal entities
(verified on 3.12: a 3-level document expands). Installing an expat
EntityDeclHandler is not an option either `XMLParser.parser` no longer
exists in 3.12, so setting it silently does nothing.
That leaves rejecting the DTD before parsing. It costs nothing: a real
multistatus never has one, and a filename containing this text would arrive
escaped as `&lt;!DOCTYPE`, so these bytes can only ever be markup.
"""
lowered = payload.lower()
if b'<!doctype' in lowered or b'<!entity' in lowered:
raise WebDAVError('Die Antwort enthält eine DTD und wurde abgelehnt.')
try:
return ET.fromstring(payload)
except (ET.ParseError, ValueError):
raise WebDAVError('Unerwartete Antwort — ist das wirklich eine WebDAV-URL?')
def _select_prop(node):
"""Return the <prop> block that actually applies.
RFC 4918 lets a server split its answer across several <propstat> blocks
typically a 200 for the properties it found and a 404 for the ones it did
not, in no guaranteed order. Taking the first one blindly mislabels
directories (whose getcontentlength is the missing property) as files, and
the client then filters them out of the listing entirely.
"""
fallback = None
for propstat in node.findall(f'{DAV_NS}propstat'):
prop = propstat.find(f'{DAV_NS}prop')
if prop is None:
continue
if ' 200 ' in (propstat.findtext(f'{DAV_NS}status') or ''):
return prop
if fallback is None:
fallback = prop
return fallback
def list_directory(source, rel_path=''):
"""PROPFIND Depth 1, returned as entries relative to the source root."""
rel_path = safe_rel_path(rel_path)
url = _build_url(source, rel_path)
if not url.endswith('/'):
url += '/'
response = _request(
'PROPFIND', url, source,
headers={'Depth': '1', 'Content-Type': 'application/xml; charset=utf-8'},
data=_PROPFIND_BODY.encode('utf-8'),
)
payload = _read_capped(
response, MAX_LISTING_BYTES,
lambda: WebDAVError('Verzeichnisliste ist unerwartet groß.'),
)
root = _parse_listing_xml(payload)
base_path = unquote(urlsplit(url).path)
entries = []
for node in root.findall(f'{DAV_NS}response'):
href = node.findtext(f'{DAV_NS}href') or ''
# hrefs come as absolute URLs, absolute paths, or relative references.
href_path = unquote(urlsplit(urljoin(url, href)).path)
if not href_path.startswith(base_path):
continue
remainder = href_path[len(base_path):].strip('/')
if not remainder:
continue # the collection we asked about
if '/' in remainder:
continue # Depth 1 should not return these, but be strict anyway
prop = _select_prop(node)
is_dir = (
prop is not None
and prop.find(f'{DAV_NS}resourcetype/{DAV_NS}collection') is not None
)
size_text = prop.findtext(f'{DAV_NS}getcontentlength') if prop is not None else None
try:
size = int(size_text) if size_text else 0
except ValueError:
size = 0
entries.append({
'name': remainder,
'path': f'{rel_path}/{remainder}' if rel_path else remainder,
'is_dir': is_dir,
'size': size,
'modified': (prop.findtext(f'{DAV_NS}getlastmodified') or '') if prop is not None else '',
'is_book': (not is_dir) and remainder.lower().endswith(BOOK_EXTENSIONS),
})
entries.sort(key=lambda e: (not e['is_dir'], e['name'].lower()))
return entries
def fetch_file(source, rel_path, max_bytes):
"""Download one file, refusing anything larger than `max_bytes`.
Reads into memory rather than to disk: the caller hands the bytes straight
back to the browser, which encrypts them. Nothing readable is persisted
server-side.
"""
rel_path = safe_rel_path(rel_path)
if not rel_path:
raise WebDAVInputError('Kein Pfad angegeben.')
if not rel_path.lower().endswith(BOOK_EXTENSIONS):
raise WebDAVInputError('Nur .epub- und .pdf-Dateien können importiert werden.')
response = _request('GET', _build_url(source, rel_path), source)
return _read_capped(
response, max_bytes,
lambda: WebDAVInputError(f'Datei ist zu groß (max. {max_bytes // 1024 // 1024} MB).'),
)

View file

@ -1,16 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0002_ebookbookmarks_ebookhighlights'),
]
operations = [
migrations.AddField(
model_name='ebookprogress',
name='position_anchor',
field=models.CharField(blank=True, default='', max_length=30),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 4.2.29 on 2026-08-04 16:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0003_ebookprogress_position_anchor'),
]
operations = [
migrations.AddField(
model_name='ebook',
name='is_read',
field=models.BooleanField(default=False),
),
]

View file

@ -9,7 +9,6 @@ class EBook(models.Model):
data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes
data_iv = models.CharField(max_length=32) # hex IV for EPUB data
uploaded_at = models.DateTimeField(auto_now_add=True)
is_read = models.BooleanField(default=False)
class Meta:
ordering = ['uploaded_at']
@ -22,7 +21,6 @@ class EBookProgress(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
scroll_fraction = models.FloatField(default=0.0)
position_anchor = models.CharField(max_length=30, blank=True, default='')
updated_at = models.DateTimeField(auto_now=True)
class Meta:

View file

@ -1,19 +1,11 @@
from django.urls import path
from . import views, webdav
from . import views
urlpatterns = [
path('', views.book_list, name='book_list'),
path('upload/', views.upload_book, name='upload_book'),
path('cloud/sources/', webdav.cloud_sources, name='cloud_sources'),
path('cloud/<int:pk>/browse/', webdav.cloud_browse, name='cloud_browse'),
path('cloud/<int:pk>/fetch/', webdav.cloud_fetch, name='cloud_fetch'),
path('metadata-lookup/', views.lookup_book_metadata, name='lookup_book_metadata'),
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
path('<int:pk>/read/', views.set_book_read, name='set_book_read'),
path('<int:pk>/meta/', views.update_book_meta, name='update_book_meta'),
path('<int:pk>/replace-data/', views.replace_book_data, name='replace_book_data'),
path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'),
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'),

View file

@ -1,9 +1,6 @@
import base64
import json
import re
import xml.etree.ElementTree as ET
import requests
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
@ -18,320 +15,28 @@ def _require_auth(request):
return None
# ---------------------------------------------------------------------------
# Library-shelf metadata lookup (DNB, falling back to Open Library)
#
# Only ever called on explicit user request (the "Metadaten" book action), never
# automatically — the server briefly sees the plaintext ISBN for this one proxied
# request, which is a deliberate, narrow exception to the "server never sees book
# content" rule (see CLAUDE.md), made because the encryption's real purpose here is
# to keep the operator from being able to see what's on the platform (piracy
# liability), not strict user privacy — an ISBN lookup against public library
# catalogs doesn't undermine that. Nothing from this lookup is persisted server-side;
# the resulting label is stored only in the client's encrypted meta blob.
# ---------------------------------------------------------------------------
# DNB "Sachgruppen der Deutschen Nationalbibliografie" / DDC divisions (hundreds -> tens),
# used to turn a raw DDC notation like "833.92" into a short shelf label.
_DDC_DIVISIONS = {
'000': 'Allgemeines, Informatik', '010': 'Bibliografien', '020': 'Bibliotheks- und Informationswissenschaft',
'030': 'Enzyklopädien', '050': 'Zeitschriften, fortlaufende Sammelwerke', '060': 'Organisationen, Museumswissenschaft',
'070': 'Nachrichtenmedien, Journalismus, Verlagswesen', '090': 'Handschriften, seltene Bücher',
'100': 'Philosophie', '130': 'Parapsychologie, Okkultismus', '150': 'Psychologie',
'200': 'Religion', '230': 'Christentum, Christliche Theologie', '290': 'Andere Religionen',
'300': 'Sozialwissenschaften, Soziologie', '310': 'Statistiken', '320': 'Politikwissenschaft',
'330': 'Wirtschaft', '340': 'Recht', '350': 'Öffentliche Verwaltung, Militärwissenschaft',
'360': 'Soziale Probleme, Sozialdienste, Versicherungen', '370': 'Erziehung, Schul- und Bildungswesen',
'380': 'Handel, Kommunikation, Verkehr', '390': 'Gebräuche, Etikette, Folklore',
'400': 'Sprache, Linguistik', '420': 'Englisch', '430': 'Deutsch, Germanische Sprachen',
'440': 'Französisch, Romanische Sprachen', '450': 'Italienisch, Rumänisch, Rätoromanisch',
'460': 'Spanisch, Portugiesisch', '470': 'Latein, Italische Sprachen', '480': 'Griechisch', '490': 'Andere Sprachen',
'500': 'Naturwissenschaften', '510': 'Mathematik', '520': 'Astronomie', '530': 'Physik', '540': 'Chemie',
'550': 'Geowissenschaften', '560': 'Paläontologie', '570': 'Biowissenschaften, Biologie',
'580': 'Pflanzen (Botanik)', '590': 'Tiere (Zoologie)',
'600': 'Technik', '610': 'Medizin, Gesundheit', '620': 'Ingenieurwissenschaften', '630': 'Landwirtschaft',
'640': 'Hauswirtschaft', '650': 'Management', '660': 'Chemische Technik', '670': 'Industrielle Fertigung',
'680': 'Fertigung für spezielle Zwecke', '690': 'Hausbau, Bauhandwerk',
'700': 'Künste', '710': 'Landschaftsgestaltung, Raumplanung', '720': 'Architektur',
'730': 'Plastik, Keramik, Metallkunst', '740': 'Zeichnung, angewandte Kunst', '750': 'Malerei',
'760': 'Grafik, Druckgrafik, Fotografie', '780': 'Musik', '790': 'Freizeit, Darstellende Kunst, Sport',
'800': 'Literatur', '810': 'Amerikanische Literatur', '820': 'Englische Literatur',
'830': 'Deutsche Literatur', '840': 'Französische Literatur', '850': 'Italienische Literatur',
'860': 'Spanische, Portugiesische Literatur', '870': 'Lateinische Literatur', '880': 'Griechische Literatur',
'890': 'Literaturen in anderen Sprachen',
'900': 'Geschichte', '910': 'Geografie, Reisen', '920': 'Biografie, Genealogie',
'930': 'Geschichte des Altertums', '940': 'Geschichte Europas', '950': 'Geschichte Asiens',
'960': 'Geschichte Afrikas', '970': 'Geschichte Nordamerikas', '980': 'Geschichte Südamerikas',
'990': 'Geschichte der übrigen Welt',
}
def _ddc_label(ddc_raw):
"""Coarsen a DDC notation ('833.92') down to its nearest known division/class label."""
digits = re.sub(r'\D', '', ddc_raw or '')
if len(digits) < 3:
return None
tens = digits[:2] + '0'
if tens in _DDC_DIVISIONS:
return _DDC_DIVISIONS[tens]
return _DDC_DIVISIONS.get(digits[0] + '00')
_MARC_NS = '{http://www.loc.gov/MARC21/slim}'
def _lookup_dnb_shelf(isbn):
url = 'https://services.dnb.de/sru/dnb'
params = {
'version': '1.1',
'operation': 'searchRetrieve',
'query': f'dnb.num={isbn}',
'recordSchema': 'MARC21-xml',
'maximumRecords': '1',
}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
root = ET.fromstring(resp.content)
for datafield in root.iter(f'{_MARC_NS}datafield'):
if datafield.get('tag') != '082':
continue
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
if subfield.get('code') == 'a' and subfield.text:
label = _ddc_label(subfield.text)
if label:
return label
return None
def _lookup_openlibrary_shelf(isbn):
url = 'https://openlibrary.org/api/books'
params = {'bibkeys': f'ISBN:{isbn}', 'jscmd': 'data', 'format': 'json'}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
data = resp.json().get(f'ISBN:{isbn}', {})
ddc = (data.get('classifications') or {}).get('dewey_decimal_class') or []
if ddc:
label = _ddc_label(ddc[0])
if label:
return label
subjects = data.get('subjects') or []
if subjects:
return subjects[0].get('name')
return None
def _cql_phrase(value):
# CQL string literals are quoted; strip embedded quotes rather than escaping them
# (this only feeds a lookup heuristic, not a stored/displayed value).
return value.replace('"', ' ').strip()
def _lookup_dnb_shelf_by_title(title, author):
url = 'https://services.dnb.de/sru/dnb'
query = f'dnb.tit="{_cql_phrase(title)}"'
if author:
query += f' and dnb.per="{_cql_phrase(author)}"'
params = {
'version': '1.1',
'operation': 'searchRetrieve',
'query': query,
'recordSchema': 'MARC21-xml',
'maximumRecords': '1',
}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
root = ET.fromstring(resp.content)
for datafield in root.iter(f'{_MARC_NS}datafield'):
if datafield.get('tag') != '082':
continue
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
if subfield.get('code') == 'a' and subfield.text:
label = _ddc_label(subfield.text)
if label:
return label
return None
def _lookup_openlibrary_shelf_by_title(title, author):
url = 'https://openlibrary.org/search.json'
params = {'title': title, 'limit': 1, 'fields': 'ddc,subject'}
if author:
params['author'] = author
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
docs = resp.json().get('docs') or []
if not docs:
return None
doc = docs[0]
ddc = doc.get('ddc') or []
if ddc:
label = _ddc_label(ddc[0])
if label:
return label
subjects = doc.get('subject') or []
if subjects:
return subjects[0]
return None
@require_http_methods(['GET'])
def lookup_book_metadata(request):
err = _require_auth(request)
if err:
return err
isbn_raw = request.GET.get('isbn', '').strip()
title = request.GET.get('title', '').strip()
author = request.GET.get('author', '').strip()
isbn = re.sub(r'[^0-9Xx]', '', isbn_raw)
if isbn_raw and len(isbn) not in (10, 13):
return JsonResponse({'error': 'invalid ISBN'}, status=400)
if not isbn and not title:
return JsonResponse({'error': 'isbn or title required'}, status=400)
label = None
source = None
# ISBN is the precise path — try it first when we have one.
if isbn:
try:
label = _lookup_dnb_shelf(isbn)
if label:
source = 'dnb'
except Exception:
pass
if not label:
try:
label = _lookup_openlibrary_shelf(isbn)
if label:
source = 'openlibrary'
except Exception:
pass
# No ISBN (or it drew a blank) — fall back to a title/author text search. Less
# precise (wrong edition/translation is possible), so the source is tagged
# distinctly for the client to hint at that if it wants to.
if not label and title:
try:
label = _lookup_dnb_shelf_by_title(title, author)
if label:
source = 'dnb-title'
except Exception:
pass
if not label:
try:
label = _lookup_openlibrary_shelf_by_title(title, author)
if label:
source = 'openlibrary-title'
except Exception:
pass
return JsonResponse({'label': label, 'source': source})
def _anchor_parts(anchor):
"""Split a position anchor 'blockIndex:innerFraction' into (block, inner).
Returns (-1, 0.0) for empty/invalid anchors (e.g. PDF progress)."""
if not isinstance(anchor, str) or ':' not in anchor:
return (-1, 0.0)
block, _, inner = anchor.partition(':')
try:
return (int(block), float(inner))
except ValueError:
return (-1, 0.0)
def _progress_is_further(new_anchor, new_frac, old_anchor, old_frac):
"""True if the new reading position is at least as far into the book as the
old one. Compares by anchor block index (precise, decoupled from layout);
falls back to scroll_fraction only when an anchor is missing (PDFs)."""
nb, ni = _anchor_parts(new_anchor)
ob, oi = _anchor_parts(old_anchor)
if nb >= 0 and ob >= 0:
return ni >= oi if nb == ob else nb >= ob
return new_frac >= old_frac
@require_http_methods(['GET'])
def book_list(request):
err = _require_auth(request)
if err:
return err
books = list(
request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read')
request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at')
)
for b in books:
b['uploaded_at'] = b['uploaded_at'].isoformat()
# Include saved scroll_fraction for each book
progress_map = {
p.book_id: (p.scroll_fraction, p.updated_at, p.position_anchor)
p.book_id: (p.scroll_fraction, p.updated_at)
for p in EBookProgress.objects.filter(user=request.user)
}
highlighted_ids = set(
EBookHighlights.objects.filter(user=request.user).values_list('book_id', flat=True)
)
for b in books:
prog = progress_map.get(b['id'])
b['scroll_fraction'] = prog[0] if prog else 0.0
b['last_read'] = prog[1].isoformat() if prog else None
b['position_anchor'] = prog[2] if prog else ''
b['has_highlights'] = b['id'] in highlighted_ids
return JsonResponse(books, safe=False)
@csrf_exempt
@require_http_methods(['POST'])
def set_book_read(request, pk):
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
book.is_read = bool(body.get('is_read', True))
book.save(update_fields=['is_read'])
return JsonResponse({'ok': True, 'is_read': book.is_read})
@csrf_exempt
@require_http_methods(['POST'])
def update_book_meta(request, pk):
"""Update only the encrypted metadata blob (e.g. to assign a folder) without touching book bytes."""
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
if not meta_ct or not meta_iv:
return JsonResponse({'error': 'meta_ct, meta_iv required'}, status=400)
book.meta_ct = meta_ct
book.meta_iv = meta_iv
book.save(update_fields=['meta_ct', 'meta_iv'])
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def upload_book(request):
@ -360,7 +65,7 @@ def upload_book(request):
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
if raw_size > max_bytes:
return JsonResponse({'error': 'file too large (max 50 MB)'}, status=400)
return JsonResponse({'error': 'file too large (max 10 MB)'}, status=400)
book = EBook.objects.create(
user=request.user,
@ -369,7 +74,6 @@ def upload_book(request):
data_ct=data_ct,
data_iv=data_iv,
)
EBookProgress.objects.create(user=request.user, book=book)
return JsonResponse({'ok': True, 'id': book.id})
@ -387,75 +91,6 @@ def get_book_data(request, pk):
return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
@csrf_exempt
@require_http_methods(['POST'])
def replace_book_data(request, pk):
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
data_ct = body.get('data_ct', '')
data_iv = body.get('data_iv', '')
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
if not all([data_ct, data_iv]):
return JsonResponse({'error': 'data_ct and data_iv required'}, status=400)
update_fields = ['data_ct', 'data_iv']
book.data_ct = data_ct
book.data_iv = data_iv
if meta_ct and meta_iv:
book.meta_ct = meta_ct
book.meta_iv = meta_iv
update_fields += ['meta_ct', 'meta_iv']
book.save(update_fields=update_fields)
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def rekey_book(request, pk):
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
data_ct = body.get('data_ct', '')
data_iv = body.get('data_iv', '')
if not all([meta_ct, meta_iv, data_ct, data_iv]):
return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400)
book.meta_ct = meta_ct
book.meta_iv = meta_iv
book.data_ct = data_ct
book.data_iv = data_iv
book.save(update_fields=['meta_ct', 'meta_iv', 'data_ct', 'data_iv'])
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def delete_book(request, pk):
@ -492,25 +127,12 @@ def save_progress(request, pk):
scroll_fraction = float(body.get('scroll_fraction', 0.0))
scroll_fraction = max(0.0, min(1.0, scroll_fraction))
raw_anchor = body.get('position_anchor', '')
position_anchor = ''
if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor):
position_anchor = raw_anchor
force = bool(body.get('force', False))
progress, _ = EBookProgress.objects.get_or_create(
user=request.user,
book=book,
)
# Always advance to the furthest-read position (by anchor block index, so a
# transiently wrong scroll_fraction can't freeze the position) unless the
# client explicitly forces a reset (e.g. "start over" button in the reader).
if force or _progress_is_further(position_anchor, scroll_fraction,
progress.position_anchor, progress.scroll_fraction):
progress.scroll_fraction = scroll_fraction
progress.position_anchor = position_anchor
progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at'])
progress.save(update_fields=['scroll_fraction', 'updated_at'])
return JsonResponse({'ok': True})
@ -550,7 +172,7 @@ def book_highlights(request, pk):
raw_size = len(base64.b64decode(ct))
except Exception:
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
if raw_size > getattr(settings, 'HIGHLIGHTS_MAX_BYTES', 700 * 1024):
if raw_size > 700 * 1024:
return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400)
row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book)
@ -595,7 +217,7 @@ def book_bookmarks(request, pk):
raw_size = len(base64.b64decode(ct))
except Exception:
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
if raw_size > getattr(settings, 'BOOKMARKS_MAX_BYTES', 100 * 1024):
if raw_size > 100 * 1024:
return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400)
row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book)

View file

@ -1,89 +0,0 @@
"""Cloud import endpoints for the ebook library.
These let the browser browse a user-configured WebDAV server and pull one book
out of it. The fetch is proxied through the server rather than done in the
browser because the WebDAV host is a different origin and will not send CORS
headers for it.
That proxy is a deliberate, narrow relaxation of the same rule the ISBN lookup
in books/views.py bends: the server sees the file's bytes in memory for the
duration of one request, but never persists them. The book is encrypted in the
browser and only then POSTed to /books/upload/ as ciphertext, exactly like a
local upload nothing readable is ever stored server-side.
"""
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from accounts.models import WebDAVSource
from accounts.webdav import WebDAVError, WebDAVInputError, list_directory, fetch_file
from .views import _require_auth
def _get_source(request, pk):
return WebDAVSource.objects.filter(pk=pk, user=request.user).first()
@require_http_methods(['GET'])
def cloud_sources(request):
unauthorized = _require_auth(request)
if unauthorized:
return unauthorized
sources = request.user.webdav_sources.all()
return JsonResponse({'sources': [
{'id': s.id, 'label': s.label, 'host': s.base_url}
for s in sources
]})
@require_http_methods(['GET'])
def cloud_browse(request, pk):
unauthorized = _require_auth(request)
if unauthorized:
return unauthorized
source = _get_source(request, pk)
if not source:
return JsonResponse({'error': 'unknown source'}, status=404)
path = request.GET.get('path', '')
try:
entries = list_directory(source, path)
except WebDAVInputError as exc:
return JsonResponse({'error': str(exc)}, status=400)
except WebDAVError as exc:
return JsonResponse({'error': str(exc)}, status=502)
source.last_used_at = timezone.now()
source.save(update_fields=['last_used_at'])
return JsonResponse({'ok': True, 'path': path, 'entries': entries})
@require_http_methods(['GET'])
def cloud_fetch(request, pk):
unauthorized = _require_auth(request)
if unauthorized:
return unauthorized
source = _get_source(request, pk)
if not source:
return JsonResponse({'error': 'unknown source'}, status=404)
path = request.GET.get('path', '')
try:
payload = fetch_file(source, path, settings.EBOOK_MAX_BYTES)
except WebDAVInputError as exc:
return JsonResponse({'error': str(exc)}, status=400)
except WebDAVError as exc:
return JsonResponse({'error': str(exc)}, status=502)
response = HttpResponse(payload, content_type='application/octet-stream')
# These are the book's plaintext bytes, on their way to be encrypted in the
# browser. Keeping them out of the HTTP disk cache (and any reverse proxy
# in front) is what makes "nothing readable is stored" true end to end.
response['Cache-Control'] = 'no-store'
return response

View file

@ -3,11 +3,3 @@ from django.conf import settings
def build_info(request):
return {'BUILD_TIME': getattr(settings, 'BUILD_TIME', '')}
def upload_limits(request):
return {
'EBOOK_MAX_BYTES': getattr(settings, 'EBOOK_MAX_BYTES', 10 * 1024 * 1024),
'BG_MAX_BYTES': getattr(settings, 'BG_MAX_BYTES', 5 * 1024 * 1024),
'PODCAST_INBOX_PAGE_SIZE': getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 200),
}

View file

@ -32,10 +32,10 @@ INSTALLED_APPS = [
'gpodder',
]
EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
# Encrypted uploads are base64-encoded (~33% overhead) so allow ~75 MB body
DATA_UPLOAD_MAX_MEMORY_SIZE = 75 * 1024 * 1024
# Encrypted uploads are base64-encoded (~33% overhead) so allow ~25 MB body
DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
@ -44,7 +44,6 @@ 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',
]
@ -63,7 +62,6 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'diora.context_processors.build_info',
'diora.context_processors.upload_limits',
],
},
},
@ -74,7 +72,7 @@ WSGI_APPLICATION = 'diora.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data' / os.environ.get('DIORA_DB_NAME', 'db.sqlite3'),
'NAME': BASE_DIR / 'data' / 'db.sqlite3',
'OPTIONS': {'timeout': 20},
}
}
@ -103,15 +101,6 @@ MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
BG_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
HIGHLIGHTS_MAX_BYTES = 700 * 1024 # 700 KB
BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB
VOLUME_DEFAULT = 204 # out of 255
ITUNES_TIMEOUT = 6 # seconds
BOOK_METADATA_TIMEOUT = 6 # seconds (DNB / Open Library shelf lookup)
WEBDAV_TIMEOUT = 15 # seconds (cloud import browse/fetch)
WEBDAV_MAX_SOURCES_PER_USER = 10
PODCAST_INBOX_PAGE_SIZE = 200
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
@ -127,10 +116,4 @@ LASTFM_API_SECRET = os.environ.get('LASTFM_API_SECRET', '')
AMAZON_AFFILIATE_TAG = os.environ.get('AMAZON_AFFILIATE_TAG', 'diora-20')
AMAZON_AFFILIATE_ENABLED = os.environ.get('AMAZON_AFFILIATE_ENABLED', 'True') == 'True'
# WebDAV cloud import (ebooks). Users supply the target URL, so by default the
# server refuses to talk to non-public addresses — otherwise any registered
# account could probe the internal network through it. Set to True only when
# every account on this instance is trusted and the WebDAV server is on the LAN.
WEBDAV_ALLOW_PRIVATE_HOSTS = os.environ.get('WEBDAV_ALLOW_PRIVATE_HOSTS', 'False') == 'True'
BUILD_TIME = os.environ.get('BUILD_TIME', '')

View file

@ -2,9 +2,6 @@ from django.conf import settings
from django.conf.urls.static import static
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),
@ -12,12 +9,5 @@ urlpatterns = [
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
# it from under /static/js/ silently restricted it to that subpath and
# navigations to '/', '/books/' etc. were never intercepted while offline.
path('sw.js', serve_static, {'document_root': settings.BASE_DIR / 'static' / 'js', 'path': 'sw.js'}),
path('', include('radio.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -1,25 +0,0 @@
// Authenticated smoke tests: reuse the storage state saved in global-setup.js
// for the fixed TEST_USER, so no login step is needed per test.
const { test, expect } = require('@playwright/test');
const path = require('path');
const { TEST_USER } = require('./env');
test.use({ storageState: path.join(__dirname, '.auth', 'user.json') });
test('logged-in home page shows the username and settings link', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.navbar-user')).toHaveText(TEST_USER.username);
await expect(page.locator('a[href="/accounts/settings/"]')).toBeVisible();
});
test('settings page is reachable while authenticated', async ({ page }) => {
await page.goto('/accounts/settings/');
await expect(page).toHaveURL(/\/accounts\/settings\//);
});
test('book list API requires auth and returns JSON for the logged-in user', async ({ request }) => {
const res = await request.get('/books/');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
});

View file

@ -1,18 +0,0 @@
// Shared constants between playwright.config.js and e2e/global-setup.js.
const PORT = 8000;
const BASE_URL = `http://127.0.0.1:${PORT}`;
const SERVER_ENV = {
...process.env,
DIORA_DB_NAME: 'e2e_test.sqlite3',
SECRET_KEY: 'e2e-test-secret-key',
DEBUG: 'True',
ALLOWED_HOSTS: 'localhost 127.0.0.1',
};
const TEST_USER = {
username: 'e2e_test_user',
password: 'e2e-test-pw-12345',
};
module.exports = { PORT, BASE_URL, SERVER_ENV, TEST_USER };

View file

@ -1,60 +0,0 @@
// Runs once before the e2e suite, after Playwright's webServer is already up
// (see playwright.config.js) but before any test executes.
//
// 1. Resets and migrates a throwaway SQLite DB (data/e2e_test.sqlite3) so
// tests never touch the real dev database.
// 2. Creates a fixed test user.
// 3. Logs that user in through the real login form and saves the resulting
// storage state, so authenticated specs can start already logged in via
// `test.use({ storageState: 'e2e/.auth/user.json' })`.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { chromium } = require('@playwright/test');
const { BASE_URL, SERVER_ENV, TEST_USER } = require('./env');
const ROOT = path.join(__dirname, '..');
const DB_PATH = path.join(ROOT, 'data', SERVER_ENV.DIORA_DB_NAME);
const AUTH_DIR = path.join(__dirname, '.auth');
const AUTH_FILE = path.join(AUTH_DIR, 'user.json');
module.exports = async function globalSetup() {
// Fresh DB every run.
fs.rmSync(DB_PATH, { force: true });
execFileSync('python3', ['manage.py', 'migrate', '--noinput'], {
cwd: ROOT,
env: SERVER_ENV,
stdio: 'inherit',
});
execFileSync(
'python3',
[
'manage.py',
'shell',
'-c',
`
from django.contrib.auth import get_user_model
User = get_user_model()
User.objects.filter(username=${JSON.stringify(TEST_USER.username)}).delete()
User.objects.create_user(${JSON.stringify(TEST_USER.username)}, "", ${JSON.stringify(TEST_USER.password)})
`,
],
{ cwd: ROOT, env: SERVER_ENV, stdio: 'inherit' }
);
fs.mkdirSync(AUTH_DIR, { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ baseURL: BASE_URL });
await page.goto(`${BASE_URL}/accounts/login/`);
await page.fill('[name=username]', TEST_USER.username);
await page.fill('[name=password]', TEST_USER.password);
await Promise.all([
page.waitForURL(BASE_URL + '/'),
page.click('button[type=submit]'),
]);
await page.context().storageState({ path: AUTH_FILE });
await browser.close();
};

View file

@ -1,37 +0,0 @@
// Unauthenticated smoke tests: no storageState, run as an anonymous visitor.
const { test, expect } = require('@playwright/test');
test('home page loads for an anonymous visitor', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/diora/);
await expect(page.locator('.navbar-brand')).toHaveText('diora');
await expect(page.locator('.navbar-links a[href="/accounts/login/"]')).toBeVisible();
});
test('login page renders the auth form', async ({ page }) => {
await page.goto('/accounts/login/');
await expect(page.locator('[name=username]')).toBeVisible();
await expect(page.locator('[name=password]')).toBeVisible();
});
test('a new user can register and lands on the home page logged in', async ({ page }) => {
const username = `e2e_reg_${Date.now()}`;
await page.goto('/accounts/register/');
await page.fill('[name=username]', username);
await page.fill('[name=password1]', 'a-very-unlikely-pw-98234');
await page.fill('[name=password2]', 'a-very-unlikely-pw-98234');
await Promise.all([
page.waitForURL('/'),
page.click('button[type=submit]'),
]);
await expect(page.locator('.navbar-user')).toHaveText(username);
});
test('login with wrong credentials shows an error and stays on the login page', async ({ page }) => {
await page.goto('/accounts/login/');
await page.fill('[name=username]', 'nobody-such-user');
await page.fill('[name=password]', 'wrong-password');
await page.click('button[type=submit]');
await expect(page).toHaveURL(/\/accounts\/login\//);
await expect(page.locator('.form-errors, .field-errors')).toBeVisible();
});

View file

@ -143,7 +143,7 @@ def subscriptions_by_device(request, username, deviceid):
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
else:
urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True))
return JsonResponse({'add': urls, 'remove': [], 'timestamp': _now_ts(), 'update_urls': []})
return JsonResponse(urls, safe=False)
elif request.method == 'POST':
try:
@ -178,7 +178,7 @@ def subscriptions_all(request, username):
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
else:
urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True))
return JsonResponse({'add': urls, 'remove': [], 'timestamp': _now_ts(), 'update_urls': []})
return JsonResponse(urls, safe=False)
# ---------------------------------------------------------------------------
@ -242,7 +242,7 @@ def episode_actions(request, username):
except Exception:
pass
return JsonResponse({'timestamp': _now_ts(), 'update_urls': []})
return JsonResponse({'timestamp': _now_ts()})
elif request.method == 'GET':
since = request.GET.get('since')

79
package-lock.json generated
View file

@ -1,79 +0,0 @@
{
"name": "diora-web",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "diora-web",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.62.1"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

View file

@ -1,20 +0,0 @@
{
"name": "diora-web",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"repository": {
"type": "git",
"url": "ssh://git@fg.creamfresh.xyz:2222/mrwnslz/diora-web.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.62.1"
}
}

View file

@ -1,37 +0,0 @@
// Playwright config for diora's e2e smoke tests.
//
// Tests run against a real `manage.py runserver` instance backed by its own
// throwaway SQLite database (DIORA_DB_NAME), so they never touch the dev
// database at data/db.sqlite3. See e2e/global-setup.js for how that DB is
// migrated and seeded with a test user.
const path = require('path');
const { defineConfig, devices } = require('@playwright/test');
const { BASE_URL, SERVER_ENV } = require('./e2e/env');
module.exports = defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
reporter: [['list']],
timeout: 30_000,
use: {
baseURL: BASE_URL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
globalSetup: require.resolve('./e2e/global-setup.js'),
webServer: {
command: 'python3 manage.py runserver 127.0.0.1:8000 --noreload',
url: BASE_URL + '/accounts/login/',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
cwd: path.join(__dirname),
env: SERVER_ENV,
},
});

View file

@ -145,7 +145,7 @@ def podcast_search(request):
try:
url = f'https://itunes.apple.com/search?term={urllib.parse.quote(q)}&media=podcast&limit=20'
resp = requests.get(url, timeout=getattr(settings, 'ITUNES_TIMEOUT', 6))
resp = requests.get(url, timeout=6)
resp.raise_for_status()
raw = resp.json().get('results', [])
except Exception as e:
@ -620,7 +620,7 @@ def inbox(request):
).values_list('episode_id', flat=True)
)
limit = min(int(request.GET.get('limit', getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 200))), 1000)
limit = min(int(request.GET.get('limit', 200)), 1000)
offset = max(int(request.GET.get('offset', 0)), 0)
episodes = list(

View file

@ -18,5 +18,4 @@ urlpatterns = [
path('radio/notes/<int:pk>/', views.save_station_notes, name='save_station_notes'),
path('radio/focus/record/', views.record_focus_session, name='record_focus_session'),
path('radio/focus/stats/', views.focus_stats, name='focus_stats'),
path('radio/stream-player/', views.stream_player, name='stream_player'),
]

View file

@ -1,6 +1,4 @@
import json
import socket
import ssl as ssl_module
import time
import urllib.parse
from datetime import datetime
@ -206,7 +204,7 @@ def affiliate_links(request):
f"https://itunes.apple.com/search"
f"?term={urllib.parse.quote(track)}&media=music&limit=1"
)
resp = requests.get(itunes_url, timeout=getattr(settings, 'ITUNES_TIMEOUT', 6))
resp = requests.get(itunes_url, timeout=5)
resp.raise_for_status()
results = resp.json().get('results', [])
if results:
@ -575,19 +573,3 @@ def import_m3u(request):
skipped += 1
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
# ---------------------------------------------------------------------------
# Minimal HTTP stream player (standalone tab for mixed-content streams)
# ---------------------------------------------------------------------------
def stream_player(request):
url = request.GET.get('url', '').strip()
name = request.GET.get('name', '').strip()
_vol_default = getattr(settings, 'VOLUME_DEFAULT', 204)
vol = request.GET.get('vol', str(_vol_default)).strip()
try:
vol = max(0, min(255, int(vol)))
except ValueError:
vol = _vol_default
return render(request, 'radio/stream_player.html', {'stream_url': url, 'stream_name': name, 'stream_vol': vol})

View file

@ -8,17 +8,6 @@
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
scrollbar-color: #333 #000;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #000; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
:root {
--bg: #000;
--bg-card: transparent;
@ -77,8 +66,7 @@ html, body {
.btn-lastfm,
.btn-danger,
.btn-primary,
.navbar-brand,
.reader-toast {
.navbar-brand {
text-shadow: none;
}
@ -108,16 +96,8 @@ a:hover {
justify-content: space-between;
height: var(--nav-h);
padding: 0 1.5rem;
background: var(--bg);
background: transparent;
border-bottom: 1px solid var(--border);
/* Window Controls Overlay: extend navbar into the titlebar area */
padding-left: max(1.5rem, env(titlebar-area-x, 1.5rem));
padding-top: env(titlebar-area-y, 0);
height: calc(var(--nav-h) + env(titlebar-area-y, 0px));
-webkit-app-region: drag;
}
.navbar a, .navbar button, .navbar input, .navbar form {
-webkit-app-region: no-drag;
}
.navbar-brand {
@ -150,9 +130,7 @@ a:hover {
.main-content {
max-width: 1100px;
margin: 0 auto;
padding: calc(var(--nav-h) + 3rem) 1.5rem calc(var(--bar-h) + 2rem);
height: 100%;
overflow-y: auto;
padding: 1rem 1.5rem calc(var(--bar-h) + 2rem);
}
/* =========================================================
@ -184,7 +162,7 @@ a:hover {
left: 0;
right: 0;
height: var(--bar-h);
background: var(--bg);
background: transparent;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
@ -316,18 +294,6 @@ a:hover {
gap: 0;
}
/* Haupttab-Leiste: immer sichtbar unterhalb der Navbar */
#tabs {
position: fixed;
top: var(--nav-h);
left: 0;
right: 0;
z-index: 150;
background: var(--bg);
margin-bottom: 0;
padding: 0 0.5rem;
}
.tab-btn {
background: none;
border: none;
@ -710,161 +676,6 @@ a:hover {
padding: 1.5rem 0;
}
/* --- WebDAV cloud connections (settings) --- */
.webdav-list {
list-style: none;
margin: 0 0 1rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.webdav-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.6rem 0.75rem;
}
.webdav-item-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
word-break: break-all;
}
.webdav-item-actions {
display: flex;
gap: 0.5rem;
}
.webdav-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.webdav-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.webdav-field input {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 0.9rem;
padding: 0.45rem 0.7rem;
outline: none;
width: 100%;
}
.webdav-field input:focus {
border-color: var(--accent);
}
.webdav-form .btn {
align-self: flex-start;
}
/* --- Cloud import browser (books tab) --- */
.cloud-browser {
display: flex;
flex-direction: column;
gap: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem;
margin-bottom: 1rem;
}
.cloud-browser-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.cloud-browser-head select {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 0.85rem;
padding: 0.3rem 0.5rem;
outline: none;
}
.cloud-path {
font-size: 0.8rem;
color: var(--text-muted);
word-break: break-all;
}
.cloud-entries {
list-style: none;
margin: 0;
padding: 0;
max-height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.cloud-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0.25rem;
border-bottom: 1px solid var(--border);
}
.cloud-entry:last-child {
border-bottom: none;
}
.cloud-entry-name {
background: none;
border: none;
color: inherit;
font-family: var(--font);
font-size: 0.9rem;
text-align: left;
padding: 0;
cursor: pointer;
word-break: break-all;
flex: 1;
min-width: 0;
}
.cloud-entry-name:hover {
color: var(--accent);
}
.cloud-entry-size {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
/* =========================================================
RESPONSIVE
========================================================= */
@ -904,10 +715,7 @@ a:hover {
@media (max-width: 600px) {
.main-content {
padding: calc(var(--nav-h) + 3rem) 0.75rem calc(var(--bar-h) + 1.5rem);
}
#tabs {
padding: 0 0.25rem;
padding: 0.75rem 0.75rem calc(var(--bar-h) + 1.5rem);
}
.now-playing-bar {
@ -926,10 +734,8 @@ a:hover {
justify-content: space-between;
}
.volume-label,
.volume-slider,
.volume-num {
display: none;
.volume-slider {
width: 60px;
}
.affiliate-section {
@ -1510,39 +1316,6 @@ body.dnd-mode .timer-display {
line-height: 1.6;
}
/* ===== MODAL DIALOG ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 500;
}
.modal-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--surface, #111);
border: 1px solid var(--border, #333);
border-radius: var(--radius);
padding: 20px;
width: 360px;
max-width: calc(100vw - 32px);
z-index: 501;
}
.modal-message {
margin: 0 0 16px;
white-space: pre-wrap;
line-height: 1.5;
}
.modal-input { width: 100%; margin-bottom: 16px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* Style links and basic HTML inside shownotes */
.sidebar-body a { color: var(--accent, #e63946); }
.sidebar-body p { margin: 0 0 10px; }
@ -1636,8 +1409,6 @@ body.dnd-mode .timer-display {
padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
}
.reader-marker-btn-mobile { display: none; }
@media (max-width: 600px) {
.sidebar { width: 100vw; }
.podcast-seek-bar { padding: 0 6px; }
@ -1645,19 +1416,6 @@ body.dnd-mode .timer-display {
.podcast-thumb-lg { width: 60px; height: 60px; }
.podcast-feed-actions { flex-direction: column; }
.episode-actions { flex-direction: column; }
/* Reader auf Mobile: Header kompakter, Fortschrittsfeld versteckt */
.reader-header { padding: 6px 8px; gap: 4px; }
.reader-title { font-size: 13px; }
.reader-header-actions { gap: 4px; }
.reader-progress-wrap { display: none; }
.reader-content { padding: 16px 10px; }
/* On a phone, opening the 150px margin just to reach the marker-mode toggle
eats too much of an already-narrow reading column surface it directly
in the main header instead (desktop keeps it only in the margin header,
grouped with "Notes", per earlier feedback against back-and-forth). */
.reader-marker-btn-mobile { display: inline-block; }
}
/* =========================================================
@ -1692,12 +1450,6 @@ body.dnd-mode .timer-display {
padding: 24px 0 16px;
}
.offline-notice {
font-size: 13px;
color: var(--accent);
padding: 4px 0 8px;
}
.book-key-bar {
display: flex;
align-items: center;
@ -1738,138 +1490,11 @@ body.dnd-mode .timer-display {
.book-progress {
font-size: 12px;
}
.book-item-meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.book-item-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.book-read-badge {
font-size: 12px;
font-weight: normal;
}
.book-shelf-badge {
display: inline-block;
font-size: 11px;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 10px;
color: var(--muted, #888);
white-space: nowrap;
}
/* --- Per-book "⋮" menu (everything except Open) --- */
.book-item-menu {
position: relative;
}
.book-item-menu-list {
display: none;
flex-direction: column;
gap: 2px;
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 20;
min-width: 220px;
background: var(--surface, #111);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.book-item-menu-list.open {
display: flex;
}
.book-menu-item {
background: none;
border: none;
color: var(--fg);
text-align: left;
padding: 8px 10px;
border-radius: var(--radius);
cursor: pointer;
font: inherit;
font-size: 13px;
white-space: nowrap;
}
.book-menu-item:hover {
background: var(--bg-row, rgba(255, 255, 255, 0.08));
}
.book-menu-item--danger {
color: var(--accent, #e63946);
}
.book-list-filter {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted, #888);
margin-top: 10px;
cursor: pointer;
}
/* --- Recently-read section (always pinned atop the books view, plain list style) --- */
.book-recent-section {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.book-recent-title {
font-size: 13px;
font-weight: 600;
margin: 0;
color: var(--muted, #888);
}
/* --- Book folders (single level) --- */
.book-folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.book-folder-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: none;
color: var(--fg);
cursor: pointer;
text-align: center;
font: inherit;
}
.book-folder-tile-icon {
font-size: 22px;
}
.book-folder-tile-name {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.book-folder-tile-count {
font-size: 11px;
color: var(--muted, #888);
}
.book-folder-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
/* --- PDF pages --- */
.pdf-page-wrapper {
@ -1895,11 +1520,6 @@ body.dnd-mode .timer-display {
z-index: 200;
display: flex;
flex-direction: column;
/* The global white text-outline (for readability over a custom background
image elsewhere in the app) is never needed here the reader always
sits on its own opaque background and it muddies highlighted text
badly when combined with a colored highlight background. */
text-shadow: none;
}
.reader-header {
display: flex;
@ -1932,8 +1552,6 @@ body.dnd-mode .timer-display {
.reader-content {
flex: 1;
overflow-y: scroll;
overflow-x: auto;
position: relative;
padding: 24px 16px;
line-height: 1.8;
font-family: Georgia, 'Times New Roman', serif;
@ -1954,13 +1572,23 @@ body.dnd-mode .timer-display {
}
/* --- Focus station sidebar --- */
/* --- Radio sidebar --- */
.rsb-nowplaying { margin-bottom: 12px; }
.rsb-station-name { font-weight: 600; }
.rsb-track { font-size: 0.85rem; margin-top: 2px; }
.rsb-controls { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.rsb-vol { display: flex; align-items: center; gap: 6px; font-size: 0.85rem; color: var(--muted, #888); }
.rsb-station-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
.focus-preset-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
margin: 10px 0;
}
.focus-preset-list li.focus-preset-active button {
border-color: var(--accent);
color: var(--accent);
}
.focus-custom-input {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
/* --- Table of contents sidebar --- */
.toc-list {
@ -2025,172 +1653,22 @@ body.dnd-mode .timer-display {
/* PDF invert */
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); }
/* ---- Immersive reader mode ---- */
.navbar { transition: transform 0.35s ease; }
.now-playing-bar{ transition: transform 0.35s ease; }
.reader-overlay { transition: top 0.35s ease, bottom 0.35s ease; }
.reader-header { overflow: hidden; max-height: 60px;
transition: max-height 0.35s ease, padding-top 0.35s ease, padding-bottom 0.35s ease; }
body.reader-immersive .navbar { transform: translateY(-100%); }
body.reader-immersive .now-playing-bar { transform: translateY(100%); }
body.reader-immersive .reader-overlay { top: 0; bottom: 0; }
body.reader-immersive .reader-header { max-height: 0; padding-top: 0; padding-bottom: 0; }
body.reader-immersive.reader-show-top .navbar { transform: none; }
body.reader-immersive.reader-show-top .reader-overlay { top: var(--nav-h); }
body.reader-immersive.reader-show-top .reader-header { max-height: 60px; padding-top: 8px; padding-bottom: 8px; }
body.reader-immersive.reader-show-bottom .now-playing-bar { transform: none; }
body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h); }
/* PDF paginated */
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
.pdf-paginated .pdf-page-wrapper { margin:0; }
/* PDF loading overlay */
.pdf-loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 20, 20, 0.85);
z-index: 200;
}
.pdf-loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #333;
border-top-color: var(--accent, #e63946);
border-radius: 50%;
animation: pdf-spin 0.7s linear infinite;
}
@keyframes pdf-spin { to { transform: rotate(360deg); } }
/* PDF two-page spread */
.pdf-spread-wrapper { display:flex; flex-direction:row; gap:8px; justify-content:center; margin-bottom:1rem; }
.pdf-spread-wrapper .pdf-page-wrapper { margin:0; }
.pdf-spread-cover { margin-bottom:1rem; }
/* Disable text selection during pinch */
#reader-content.pinch-active { user-select:none; -webkit-user-select:none; }
/* Highlight popover */
.highlight-popover { position:fixed; z-index:500; display:flex; gap:6px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.5); }
.hl-color-btn { width:24px; height:24px; border-radius:50%; border:2px solid transparent; cursor:pointer; font-weight:700; font-size:12px; color:#000; line-height:1; }
.hl-color-btn:hover { border-color:#fff; }
.hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; }
/* Footnote popover */
.footnote-popover { position:fixed; z-index:500; max-width:min(400px,90vw); max-height:220px; overflow-y:auto; background:#1a1a1a; color:#e8e8e8; border:1px solid #444; border-radius:var(--radius); padding:10px 28px 10px 12px; box-shadow:0 4px 20px rgba(0,0,0,.6); font-size:0.88em; line-height:1.55; pointer-events:auto; }
.footnote-popover.reader-theme-sepia { background:#ede0c4; color:#3b2a1a; border-color:#c8b89a; }
.footnote-popover.reader-theme-bright { background:#f0f0f0; color:#111; border-color:#bbb; }
.footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; }
/* Highlight marks */
.reader-no-bold * { font-weight: normal !important; }
.reader-content img { max-width: 100%; height: auto; display: block; }
/* color:inherit overrides the browser's UA-default `mark { color: black }`
without it, highlighted text abruptly flips to black regardless of the
reader theme's own text color (jarring on the default dark theme, whose
body text is white). Inheriting keeps highlighted text the same color as
the text around it in every theme; the background tones below are chosen
dark/saturated enough to stay readable specifically against white text. */
.epub-highlight { border-radius:2px; cursor:pointer; color:inherit; }
.epub-highlight[data-color="yellow"] { background:rgba(184,134,11,.55); }
.epub-highlight[data-color="green"] { background:rgba(30,140,90,.55); }
.epub-highlight[data-color="blue"] { background:rgba(41,110,180,.55); }
.epub-highlight[data-color="red"] { background:rgba(190,50,60,.55); }
/* Margin ("page margin") panel left of the reader content. Highlights without
a note show as a small color dot; anything with note text (freeform notes or
a highlight+note) shows as a readable little "post-it" card, positioned next
to the text height it belongs to. Click empty space to place a new freeform
note anchored to that line. Freeform notes have NO in-text mark the anchor
is purely positional, only ever visible here in the margin. */
.reader-body-row { display:flex; flex:1; min-height:0; }
.reader-margin {
width:0; flex-shrink:0; overflow:hidden;
display:flex; flex-direction:column;
border-right:1px solid var(--border); background:var(--bg);
transition:width 0.25s ease;
}
.reader-margin.open { width:220px; }
.reader-margin-header {
display:none; align-items:center; justify-content:space-between;
padding:6px 8px; border-bottom:1px solid var(--border); white-space:nowrap; flex-shrink:0;
}
.reader-margin.open .reader-margin-header { display:flex; }
.reader-margin-title { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted,#888); }
.reader-margin-header-actions { display:flex; align-items:center; gap:6px; }
.reader-margin-markers { position:relative; flex:1; overflow-y:auto; overflow-x:hidden; cursor:crosshair; }
.margin-note { position:absolute; left:8px; right:8px; cursor:pointer; }
.margin-note-dot { left:8px; right:auto; width:12px; height:12px; border-radius:50%; }
.margin-note-dot[data-color="yellow"] { background:rgba(241,196,15,.85); }
.margin-note-dot[data-color="green"] { background:rgba(46,204,113,.85); }
.margin-note-dot[data-color="blue"] { background:rgba(52,152,219,.85); }
.margin-note-dot[data-color="red"] { background:rgba(230,57,70,.85); }
.margin-note-text {
font-size:11px; line-height:1.4; padding:5px 7px; border-radius:3px;
background:var(--bg-card,#1a1a1a); border-left:3px solid var(--muted,#888);
box-shadow:1px 2px 5px rgba(0,0,0,.3);
white-space:pre-wrap; word-break:break-word;
}
.margin-note-text[data-color="yellow"] { border-left-color:#f1c40f; }
.margin-note-text[data-color="green"] { border-left-color:#2ecc71; }
.margin-note-text[data-color="blue"] { border-left-color:#3498db; }
.margin-note-text[data-color="red"] { border-left-color:#e63946; }
/* Freeform notes get a paper-like tint, like a post-it note pinned to the text */
.margin-note-text.margin-note-freeform {
border-left-color:#e6c229; background:rgba(241,196,15,.13);
}
/* Inline note editor appears right where the note lives in the margin, so
setting/editing a note never means jumping to a separate panel */
.margin-note-editor { position:absolute; left:8px; right:8px; z-index:1; }
.margin-note-textarea {
width:100%; min-height:52px; font-size:11px; line-height:1.4; padding:5px 7px;
border-radius:3px; border:1px solid var(--accent,#e63946);
background:var(--bg-card,#1a1a1a); color:var(--fg,#eee);
box-shadow:1px 2px 5px rgba(0,0,0,.4); resize:vertical;
}
/* Note bottom sheet the mobile alternative to the inline margin editor.
Typing a note into a 150px-wide margin column is unpleasant on a phone
keyboard, and getting there first requires opening that column at all, so
on narrow viewports note editing uses this full-width sheet instead (see
_isMobileReader() / _openNoteEditor() in app.js). Existing notes staying
less discoverable on mobile is an accepted tradeoff reviewing them in
depth is expected to happen on desktop; setting them fast is what matters
here. */
.note-bottom-sheet { position:fixed; inset:0; z-index:650; }
.note-bottom-sheet-backdrop {
position:absolute; inset:0; background:rgba(0,0,0,.5);
opacity:0; transition:opacity .2s ease; pointer-events:none;
}
.note-bottom-sheet.open .note-bottom-sheet-backdrop { opacity:1; pointer-events:auto; }
.note-bottom-sheet-panel {
position:absolute; left:0; right:0; bottom:0;
background:var(--surface,#111); border-top:1px solid var(--border,#333);
border-radius:12px 12px 0 0; padding:12px 14px calc(14px + env(safe-area-inset-bottom));
transform:translateY(100%); transition:transform .25s ease;
display:flex; flex-direction:column; gap:10px;
}
.note-bottom-sheet.open .note-bottom-sheet-panel { transform:translateY(0); }
.note-bottom-sheet-textarea {
width:100%; min-height:110px; font-size:15px; line-height:1.4; padding:8px 10px;
border-radius:var(--radius); border:1px solid var(--border,#333);
background:var(--bg-card,#1a1a1a); color:var(--fg,#eee); resize:vertical;
}
.note-bottom-sheet-actions { display:flex; justify-content:flex-end; gap:8px; }
@media (max-width: 600px) {
.reader-margin.open { width:150px; }
.reader-margin-title { display:none; }
}
.epub-highlight { border-radius:2px; cursor:pointer; }
.epub-highlight[data-color="yellow"] { background:rgba(241,196,15,.4); }
.epub-highlight[data-color="green"] { background:rgba(46,204,113,.35); }
.epub-highlight[data-color="blue"] { background:rgba(52,152,219,.35); }
.epub-highlight[data-color="red"] { background:rgba(230,57,70,.35); }
/* Search matches */
mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border-radius:2px; }
@ -2204,11 +1682,6 @@ mark.reader-search-match.active { background:rgba(230,57,70,.7); }
.reader-toast { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); background:var(--fg); color:var(--bg); padding:6px 14px; border-radius:var(--radius); font-size:13px; z-index:600; animation:toast-fade 2s ease forwards; pointer-events:none; }
@keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} }
.reader-toast-action { display:flex; align-items:center; gap:10px; padding:8px 8px 8px 14px; animation:toast-slide-up 0.2s ease forwards; pointer-events:auto; }
.reader-toast-btn { background:var(--accent,#e63946); color:#fff; border:none; border-radius:calc(var(--radius) - 2px); padding:5px 12px; font-family:var(--font); font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; }
.reader-toast-btn:hover { filter:brightness(1.1); }
@keyframes toast-slide-up { 0%{opacity:0; transform:translate(-50%, 8px);} 100%{opacity:1; transform:translate(-50%, 0);} }
.build-time {
position: fixed;
bottom: 4px;

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use.
*/
const CACHE = 'diora-v41';
const CACHE = 'diora-v7';
const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [
'/static/css/app.css',
@ -31,13 +31,6 @@ self.addEventListener('activate', function (event) {
);
}).then(function () {
return self.clients.claim();
}).then(function () {
// Tell all open tabs to reload so they get the latest cached assets
return self.clients.matchAll({type: 'window'}).then(function (clients) {
clients.forEach(function (client) {
client.postMessage({type: 'SW_ACTIVATED'});
});
});
})
);
});
@ -78,7 +71,7 @@ self.addEventListener('fetch', function (event) {
return;
}
// Cache-first for pre-defined shell assets
// Cache-first only for pre-defined shell assets; everything else hits the network
const isShell = SHELL.some(function (s) { return url.pathname === s; });
if (isShell) {
event.respondWith(
@ -86,22 +79,5 @@ self.addEventListener('fetch', function (event) {
return cached || fetch(event.request);
})
);
return;
}
// Navigation requests (the HTML page itself): network-first, fall back to
// last cached version so the app opens offline after being visited once.
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).then(function (response) {
if (response.ok) {
var clone = response.clone();
caches.open(CACHE).then(function (cache) { cache.put(event.request, clone); });
}
return response;
}).catch(function () {
return caches.match(event.request);
})
);
}
});

View file

@ -4,7 +4,6 @@
"description": "Internet radio player",
"start_url": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#000000",
"theme_color": "#000000",
"orientation": "any",
@ -13,25 +12,13 @@
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
"purpose": "any maskable"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
"purpose": "any maskable"
}
]
}

View file

@ -72,128 +72,11 @@
<section class="settings-section">
<h2>Account</h2>
<p>Logged in as <strong>{{ request.user.username }}</strong></p>
<details {% if password_form_open %}open{% endif %} style="margin-top:1rem;">
<summary class="btn" style="display:inline-block;cursor:pointer;">Change password</summary>
<form id="pw-change-form" method="post" action="{% url 'change_password' %}" style="margin-top:1rem; display:flex; flex-direction:column; gap:0.6rem; max-width:320px;">
{% csrf_token %}
{% for field in password_form %}
<div>
<label style="display:block; font-size:0.85rem; margin-bottom:2px;">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="message message-error" style="margin-top:4px; padding:4px 8px; font-size:0.8rem;">{{ field.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
{% if password_form.non_field_errors %}
<div class="message message-error" style="padding:4px 8px; font-size:0.8rem;">{{ password_form.non_field_errors|join:", " }}</div>
{% endif %}
<div id="pw-change-status" style="font-size:0.85rem; color:#888;"></div>
<div><button type="submit" class="btn">Save</button></div>
</form>
</details>
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
<form method="post" action="{% url 'logout' %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn">Logout</button>
</form>
</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>
<!-- Cloud connections (WebDAV / Nextcloud) for the ebook import -->
<section class="settings-section">
<h2>Cloud-Verbindungen für Bücher</h2>
<p class="lastfm-description">
Verbinde einen WebDAV-Server — Nextcloud, ownCloud, Synology oder was auch immer WebDAV
spricht — und importiere <code>.epub</code>- und <code>.pdf</code>-Dateien direkt daraus in
deine Bibliothek. Die Datei läuft dabei nur durch den Server hindurch; verschlüsselt wird sie
wie immer erst in deinem Browser, gespeichert wird ausschließlich der Geheimtext.
</p>
<p class="lastfm-description">
Lege dafür bitte ein <strong>App-Passwort</strong> an (bei Nextcloud unter
Einstellungen → Sicherheit) statt dein Konto-Passwort einzutragen — es wird serverseitig
gespeichert und lässt sich jederzeit einzeln widerrufen.
</p>
{% if webdav_sources %}
<ul class="webdav-list">
{% for source in webdav_sources %}
<li class="webdav-item">
<div class="webdav-item-info">
<strong>{{ source.label }}</strong>
<span class="muted">{{ source.base_url }}{% if source.root_path %} · /{{ source.root_path }}{% endif %}</span>
</div>
<div class="webdav-item-actions">
<form method="post" action="{% url 'webdav_test' source.pk %}" class="inline-form">
{% csrf_token %}
<button type="submit" class="btn">Testen</button>
</form>
<form method="post" action="{% url 'webdav_delete' source.pk %}" class="inline-form"
onsubmit="return confirm('Verbindung „{{ source.label|escapejs }}“ entfernen?');">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Entfernen</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
<form method="post" action="{% url 'webdav_add' %}" class="webdav-form">
{% csrf_token %}
<label class="webdav-field">
<span>Name</span>
<input type="text" name="label" required placeholder="Meine Nextcloud">
</label>
<label class="webdav-field">
<span>Server-URL</span>
<input type="url" name="base_url" required placeholder="https://cloud.example.com">
</label>
<label class="webdav-field">
<span>Benutzername</span>
<input type="text" name="username" autocomplete="off" placeholder="dein-login">
</label>
<label class="webdav-field">
<span>App-Passwort</span>
<input type="password" name="password" autocomplete="new-password">
</label>
<label class="webdav-field">
<span>Unterordner <span class="muted">(optional)</span></span>
<input type="text" name="root_path" placeholder="Buecher">
</label>
<p class="lastfm-description" style="margin:0;">
Bei Nextcloud/ownCloud genügt die Server-Adresse — der WebDAV-Pfad wird aus dem
Benutzernamen ergänzt. Andere Server brauchen die vollständige WebDAV-URL.
</p>
<button type="submit" class="btn">Verbindung hinzufügen</button>
</form>
</section>
</div>
{% endblock %}
@ -238,11 +121,6 @@ async function _getOrCreateEncKey() {
return key;
}
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
const ok = confirm('Nach dem Ändern des Passworts können alle hochgeladenen Bücher nicht mehr geöffnet werden und müssen erneut hochgeladen werden. Fortfahren?');
if (!ok) e.preventDefault();
});
async function uploadBackground(input) {
const file = input.files[0];
if (!file) return;

View file

@ -13,7 +13,6 @@
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="stylesheet" href="/static/css/app.css">
<title>{% block title %}diora{% endblock %}</title>
<script>const DIORA_CONFIG = { ebookMaxBytes: {{ EBOOK_MAX_BYTES }}, bgMaxBytes: {{ BG_MAX_BYTES }}, podcastInboxPageSize: {{ PODCAST_INBOX_PAGE_SIZE }} };</script>
{% if encrypted_bg_json %}
<script>const ENCRYPTED_BG = {{ encrypted_bg_json|safe }};</script>
{% endif %}

View file

@ -18,14 +18,14 @@
</label>
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</button>
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)"></button>
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio"></button>
<button class="btn-icon" id="focus-station-btn" onclick="openFocusStationSidebar()" title="Focus station">📻</button>
</div>
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">«&thinsp;15</button>
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">&thinsp;15</button>
<span class="seek-time" id="seek-current">0:00</span>
<input type="range" id="seek-slider" class="seek-slider" min="0" max="100" value="0">
<span class="seek-time" id="seek-duration">0:00</span>
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30&thinsp;»</button>
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30&thinsp;</button>
<div class="speed-btns" id="speed-btns">
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</button>
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button>
@ -43,7 +43,7 @@
<button class="btn-icon" id="timer-toggle-btn" onclick="toggleTimer()" title="Start/pause timer"></button>
<button class="btn-icon" id="timer-reset-btn" onclick="resetTimer()" title="Reset timer"></button>
<span class="focus-today" id="focus-today-widget" style="display:none;"></span>
<button class="btn-icon dnd-only" id="dnd-light-btn" onclick="toggleDNDLight()" title="Toggle black background"></button>
<button class="btn-icon dnd-only" id="dnd-light-btn" onclick="toggleDNDLight()" title="Toggle black background">💡</button>
</div>
</section>
@ -131,7 +131,7 @@
<table class="data-table" id="saved-table">
<thead>
<tr>
<th title="Favorite">&#9733;</th>
<th>&#9733;</th>
<th>Name</th>
<th>Bitrate</th>
<th>Country</th>
@ -319,21 +319,7 @@
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
<span id="book-upload-status" class="muted"></span>
</div>
<button type="button" class="btn" style="margin:0.5rem 0;" onclick="toggleCloudImport()">☁ Aus Cloud importieren</button>
<div id="cloud-browser" class="cloud-browser" style="display:none;">
<div class="cloud-browser-head">
<select id="cloud-source-select" onchange="_onCloudSourceChange(this.value)"></select>
<button type="button" class="btn" onclick="closeCloudImport()">Schließen</button>
</div>
<div id="cloud-path" class="cloud-path"></div>
<ul id="cloud-entries" class="cloud-entries"></ul>
<span id="cloud-status" class="muted"></span>
</div>
</div>
<label class="book-list-filter">
<input type="checkbox" id="book-show-read-toggle" onchange="_onBookShowReadToggle(this.checked)">
Gelesene Bücher anzeigen
</label>
<div id="book-list" class="book-list"></div>
{% else %}
<p class="auth-prompt">
@ -349,35 +335,19 @@
<span id="reader-title" class="reader-title"></span>
<div class="reader-header-actions">
<span class="reader-progress-wrap">
<input type="text" inputmode="decimal" id="reader-progress-input" class="volume-num" value="0" style="display:none;">
<input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;">
<span id="reader-progress-suffix" class="muted"></span>
</span>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search"></button>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">🔍</button>
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button>
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button>
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button>
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button>
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents"></button>
<button class="btn-icon" id="reader-margin-btn" onclick="toggleAnnotationsMargin()" title="Notes &amp; highlights"></button>
<button class="btn-icon marker-mode-btn reader-marker-btn-mobile" id="reader-marker-btn-mobile" onclick="toggleMarkerMode()" title="Text markieren"></button>
<button class="btn-icon" id="reader-reset-pos-btn" onclick="saveReaderProgress(true)" title="Diese Position als Lesefortschritt setzen (überschreibt gespeicherten Fortschritt)"></button>
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)"></button>
</div>
</div>
<div id="reader-body-row" class="reader-body-row">
<aside id="reader-margin" class="reader-margin">
<div class="reader-margin-header">
<span class="reader-margin-title">Notes</span>
<span class="reader-margin-header-actions">
<button class="btn-icon marker-mode-btn" id="reader-marker-btn" onclick="toggleMarkerMode()" title="Mark text"></button>
<button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="List all highlights &amp; notes, jump to any of them"></button>
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file"></button>
</span>
</div>
<div id="reader-margin-markers" class="reader-margin-markers"></div>
</aside>
<div id="reader-content" class="reader-content"></div>
</div>
</div>
<!-- ===== SIDEBAR ===== -->
<div id="sidebar-overlay" class="sidebar-overlay" onclick="closeSidebar()" style="display:none;"></div>
@ -389,17 +359,6 @@
<div id="sidebar-body" class="sidebar-body"></div>
</aside>
<!-- ===== MODAL DIALOG (replaces native alert/confirm/prompt) ===== -->
<div id="modal-overlay" class="modal-overlay" style="display:none;"></div>
<div id="modal-dialog" class="modal-dialog" style="display:none;" role="alertdialog" aria-modal="true">
<p id="modal-message" class="modal-message"></p>
<input type="text" id="modal-input" class="search-input modal-input" style="display:none;">
<div class="modal-actions">
<button type="button" id="modal-cancel-btn" class="btn" style="display:none;">Cancel</button>
<button type="button" id="modal-ok-btn" class="btn btn-primary">OK</button>
</div>
</div>
{% endblock %}
{% block extra_js %}

View file

@ -1,162 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ stream_name|default:"Radio" }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0d0d0d;
color: #e0e0e0;
min-height: 100svh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 24px;
}
#station-name {
font-size: 1.3rem;
font-weight: 700;
text-align: center;
}
#track-name {
font-size: 0.9rem;
color: #888;
text-align: center;
min-height: 1.2em;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
}
#play-btn {
background: #e63946;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-size: 1rem;
cursor: pointer;
min-width: 90px;
}
#play-btn:hover { background: #c1121f; }
.vol-wrap {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: #888;
}
#vol-slider { width: 90px; accent-color: #e63946; }
#vol-num {
width: 42px;
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
border-radius: 4px;
padding: 2px 4px;
font-size: 0.85rem;
text-align: center;
}
#back-btn {
background: none;
border: none;
color: #555;
font-size: 0.8rem;
cursor: pointer;
text-decoration: underline;
margin-top: 8px;
}
#back-btn:hover { color: #aaa; }
</style>
</head>
<body>
<div id="station-name">{{ stream_name|default:"Radio" }}</div>
<div id="track-name"></div>
<div class="controls">
<button id="play-btn">&#9654; Play</button>
<div class="vol-wrap">
<span>vol</span>
<input type="range" id="vol-slider" min="0" max="255" value="{{ stream_vol }}">
<input type="number" id="vol-num" min="0" max="255" value="{{ stream_vol }}">
</div>
</div>
<button id="back-btn" onclick="window.close()">&#8592; close tab</button>
<script>
const audio = new Audio();
let playing = false;
let sse = null;
const streamUrl = '{{ stream_url|escapejs }}';
const stationName = '{{ stream_name|escapejs }}';
// Volume
function setVol(v) {
v = Math.max(0, Math.min(255, Math.round(v)));
audio.volume = v / 255;
document.getElementById('vol-slider').value = v;
document.getElementById('vol-num').value = v;
try { localStorage.setItem('diora_volume', v); } catch (_) {}
}
const slider = document.getElementById('vol-slider');
const numIn = document.getElementById('vol-num');
slider.addEventListener('input', () => setVol(parseInt(slider.value, 10)));
numIn.addEventListener('change', () => setVol(parseInt(numIn.value, 10)));
// Play / Stop
const playBtn = document.getElementById('play-btn');
function startPlay() {
audio.src = streamUrl;
setVol(parseInt(slider.value, 10));
audio.play().then(() => {
playing = true;
playBtn.innerHTML = '&#9646;&#9646; Stop';
if (sse) sse.close();
sse = new EventSource('/radio/sse/?url=' + encodeURIComponent(streamUrl));
sse.onmessage = e => {
try {
const data = JSON.parse(e.data);
if (data.track) {
document.getElementById('track-name').textContent = data.track;
document.title = data.track + ' — ' + stationName;
}
} catch (_) {}
};
}).catch(() => {
// Autoplay blocked — reset state, prompt user to click
playing = false;
audio.src = '';
playBtn.innerHTML = '&#9654; Play';
document.getElementById('track-name').textContent = 'Click Play to start';
});
}
function stopPlay() {
audio.pause();
audio.src = '';
playing = false;
playBtn.innerHTML = '&#9654; Play';
document.getElementById('track-name').textContent = '';
document.title = stationName;
if (sse) { sse.close(); sse = null; }
}
playBtn.addEventListener('click', () => {
if (playing) stopPlay(); else startPlay();
});
document.getElementById('track-name').textContent = 'Click Play to start';
</script>
</body>
</html>