diff --git a/.env.example b/.env.example index d869df1..33cc233 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,8 @@ 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 diff --git a/accounts/migrations/0005_webdavsource.py b/accounts/migrations/0005_webdavsource.py new file mode 100644 index 0000000..910650e --- /dev/null +++ b/accounts/migrations/0005_webdavsource.py @@ -0,0 +1,33 @@ +# 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'], + }, + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 616a150..38e8229 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,4 +1,5 @@ import secrets +from urllib.parse import urlsplit from django.db import models from django.contrib.auth.models import User @@ -37,6 +38,57 @@ def save_user_profile(sender, instance, **kwargs): 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) diff --git a/accounts/tests.py b/accounts/tests.py index 49d830c..4d4e05a 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,9 +1,13 @@ 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 +from django.test import TestCase, override_settings -from .models import ApiToken +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 @@ -89,3 +93,436 @@ class SyncSnapshotTests(TestCase): # 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''' + + + /remote.php/dav/files/alice/Books/ + + + + /remote.php/dav/files/alice/Books/Sci-Fi/ + + + + /remote.php/dav/files/alice/Books/Der%20Steppenwolf.epub + 4096 + + + /remote.php/dav/files/alice/Books/notes.txt + 12 + +''' + + +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''' + + + + + +]> +&lol4;''' + +# 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''' + + + /remote.php/dav/files/alice/Books/ + + HTTP/1.1 200 OK + + + /remote.php/dav/files/alice/Books/Sci-Fi/ + + HTTP/1.1 404 Not Found + + HTTP/1.1 200 OK + +''' + +RELATIVE_HREF_RESPONSE = b''' + + Dune.epub + 7 + HTTP/1.1 200 OK + +''' + + +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') diff --git a/accounts/urls.py b/accounts/urls.py index 82524c1..4bee71f 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -16,4 +16,7 @@ urlpatterns = [ 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//test/', views.webdav_test, name='webdav_test'), + path('webdav//delete/', views.webdav_delete, name='webdav_delete'), ] diff --git a/accounts/views.py b/accounts/views.py index 0e71d7a..519c073 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,6 +1,7 @@ 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.decorators import login_required from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, PasswordChangeForm @@ -11,7 +12,8 @@ from django.views.decorators.http import require_http_methods from radio import lastfm as lastfm_module -from .models import ApiToken +from .models import ApiToken, WebDAVSource +from .webdav import WebDAVError, list_directory User = get_user_model() @@ -75,6 +77,7 @@ def settings_view(request): '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) @@ -213,6 +216,7 @@ def change_password(request): 'password_form': form, 'password_form_open': True, 'api_token': getattr(request.user, 'api_token', None), + 'webdav_sources': request.user.webdav_sources.all(), }) @@ -236,3 +240,73 @@ 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') diff --git a/accounts/webdav.py b/accounts/webdav.py new file mode 100644 index 0000000..3c0c5c6 --- /dev/null +++ b/accounts/webdav.py @@ -0,0 +1,380 @@ +"""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 = ( + '' + '' + '' + '' +) + + +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 `<!DOCTYPE`, so these bytes can only ever be markup. + """ + lowered = payload.lower() + if b' block that actually applies. + + RFC 4918 lets a server split its answer across several 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).'), + ) diff --git a/books/urls.py b/books/urls.py index e17e99f..a9d738b 100644 --- a/books/urls.py +++ b/books/urls.py @@ -1,9 +1,12 @@ from django.urls import path -from . import views +from . import views, webdav 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//browse/', webdav.cloud_browse, name='cloud_browse'), + path('cloud//fetch/', webdav.cloud_fetch, name='cloud_fetch'), path('metadata-lookup/', views.lookup_book_metadata, name='lookup_book_metadata'), path('/data/', views.get_book_data, name='get_book_data'), path('/delete/', views.delete_book, name='delete_book'), diff --git a/books/webdav.py b/books/webdav.py new file mode 100644 index 0000000..11ed64f --- /dev/null +++ b/books/webdav.py @@ -0,0 +1,89 @@ +"""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 diff --git a/diora/settings.py b/diora/settings.py index 53f6905..b8c8b21 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -109,6 +109,8 @@ 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' @@ -125,4 +127,10 @@ 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', '') diff --git a/static/css/app.css b/static/css/app.css index af1ecf6..b451796 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -710,6 +710,161 @@ 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 ========================================================= */ diff --git a/static/js/app.js b/static/js/app.js index 39f793a..263f25f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3776,23 +3776,31 @@ async function deriveAndStoreKey() { async function uploadEbook(file) { const statusEl = $('book-upload-status'); - const isPdf = /\.pdf$/i.test(file.name); - const isEpub = /\.epub$/i.test(file.name); - if (!isPdf && !isEpub) { - if (statusEl) statusEl.textContent = 'Only .epub and .pdf files are supported.'; - return; - } if (file.size > DIORA_CONFIG.ebookMaxBytes) { if (statusEl) statusEl.textContent = `File too large (max ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`; - return; + return false; + } + return _importEbookBuffer(await file.arrayBuffer(), file.name, statusEl); +} + +// Shared tail of every import path (local file, cloud): parse metadata, encrypt +// with the user's key and POST ciphertext. Plaintext never leaves the browser. +async function _importEbookBuffer(buf, filename, statusEl) { + const isPdf = /\.pdf$/i.test(filename); + const isEpub = /\.epub$/i.test(filename); + if (!isPdf && !isEpub) { + if (statusEl) statusEl.textContent = 'Only .epub and .pdf files are supported.'; + return false; + } + if (buf.byteLength > DIORA_CONFIG.ebookMaxBytes) { + if (statusEl) statusEl.textContent = `File too large (max ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`; + return false; } if (statusEl) statusEl.textContent = 'Encrypting…'; try { - const buf = await file.arrayBuffer(); - - let title = file.name.replace(/\.(epub|pdf)$/i, ''); + let title = filename.replace(/\.(epub|pdf)$/i, ''); let author = ''; let isbn = ''; const type = isPdf ? 'pdf' : 'epub'; @@ -3821,7 +3829,7 @@ async function uploadEbook(file) { } const key = await getOrCreateEncKey(); - const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type, isbn, folder: '', shelfTag: ''})); + const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename, type, isbn, folder: '', shelfTag: ''})); const [metaEnc, dataEnc] = await Promise.all([ encryptBytes(key, metaJson), encryptBytes(key, buf), @@ -3843,11 +3851,165 @@ async function uploadEbook(file) { if (data.ok) { if (statusEl) statusEl.textContent = `✓ "${title}" uploaded`; loadBookList(); - } else { - if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed'); + return true; } + if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed'); + return false; } catch (e) { if (statusEl) statusEl.textContent = 'Upload failed: ' + e.message; + return false; + } +} + +// --------------------------------------------------------------------------- +// Cloud import — browse a user-configured WebDAV server and pull a book in. +// +// The download is proxied by the server because the remote host is a different +// origin and sends no CORS headers. It still lands here as raw bytes and goes +// through _importEbookBuffer like any local file, so what gets stored is +// ciphertext the server cannot read. +// --------------------------------------------------------------------------- + +let _cloudSources = []; +let _cloudSourceId = null; +let _cloudPath = ''; + +async function toggleCloudImport() { + const panel = $('cloud-browser'); + if (!panel) return; + if (panel.style.display !== 'none') { closeCloudImport(); return; } + + try { + const res = await fetch('/books/cloud/sources/'); + _cloudSources = (await res.json()).sources || []; + } catch (e) { + await customAlert('Cloud-Verbindungen konnten nicht geladen werden: ' + e.message); + return; + } + + if (!_cloudSources.length) { + await customAlert( + 'Noch keine Cloud-Verbindung eingerichtet. Unter Einstellungen → ' + + '„Cloud-Verbindungen für Bücher“ kannst du eine hinzufügen.' + ); + return; + } + + const select = $('cloud-source-select'); + if (select) { + select.innerHTML = ''; + for (const source of _cloudSources) { + const option = document.createElement('option'); + option.value = source.id; + option.textContent = source.label; + select.appendChild(option); + } + select.style.display = _cloudSources.length > 1 ? '' : 'none'; + } + + panel.style.display = ''; + _cloudSourceId = _cloudSources[0].id; + _cloudBrowse(''); +} + +function closeCloudImport() { + const panel = $('cloud-browser'); + if (panel) panel.style.display = 'none'; +} + +function _onCloudSourceChange(value) { + _cloudSourceId = parseInt(value, 10); + _cloudBrowse(''); +} + +function _cloudParentPath(path) { + const parts = path.split('/').filter(Boolean); + parts.pop(); + return parts.join('/'); +} + +function _formatBytes(n) { + if (!n) return ''; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB'; + return (n / 1024 / 1024).toFixed(1) + ' MB'; +} + +function _cloudEntryRow(label, onClick, sizeText) { + const li = document.createElement('li'); + li.className = 'cloud-entry'; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'cloud-entry-name'; + button.textContent = label; + button.addEventListener('click', onClick); + li.appendChild(button); + + if (sizeText) { + const size = document.createElement('span'); + size.className = 'cloud-entry-size'; + size.textContent = sizeText; + li.appendChild(size); + } + return li; +} + +async function _cloudBrowse(path) { + const listEl = $('cloud-entries'); + const pathEl = $('cloud-path'); + const statusEl = $('cloud-status'); + if (!listEl) return; + + if (statusEl) statusEl.textContent = 'Lade…'; + listEl.innerHTML = ''; + + let data; + try { + const res = await fetch(`/books/cloud/${_cloudSourceId}/browse/?path=${encodeURIComponent(path)}`); + data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + } catch (e) { + if (statusEl) statusEl.textContent = 'Fehler: ' + e.message; + return; + } + + _cloudPath = data.path || ''; + if (pathEl) pathEl.textContent = '/' + _cloudPath; + if (statusEl) statusEl.textContent = ''; + + if (_cloudPath) { + listEl.appendChild(_cloudEntryRow('⬑ ..', () => _cloudBrowse(_cloudParentPath(_cloudPath)))); + } + + const entries = (data.entries || []).filter(e => e.is_dir || e.is_book); + if (!entries.length && statusEl) { + statusEl.textContent = 'Keine Bücher oder Ordner in diesem Verzeichnis.'; + } + + for (const entry of entries) { + listEl.appendChild(entry.is_dir + ? _cloudEntryRow('📁 ' + entry.name, () => _cloudBrowse(entry.path)) + : _cloudEntryRow('📖 ' + entry.name, () => _cloudImport(entry), _formatBytes(entry.size))); + } +} + +async function _cloudImport(entry) { + const statusEl = $('cloud-status'); + if (statusEl) statusEl.textContent = `Lade „${entry.name}“…`; + + try { + const res = await fetch(`/books/cloud/${_cloudSourceId}/fetch/?path=${encodeURIComponent(entry.path)}`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `HTTP ${res.status}`); + } + const buf = await res.arrayBuffer(); + if (await _importEbookBuffer(buf, entry.name, statusEl) && statusEl) { + statusEl.textContent = `✓ „${entry.name}“ importiert`; + } + } catch (e) { + if (statusEl) statusEl.textContent = 'Import fehlgeschlagen: ' + e.message; } } diff --git a/static/js/sw.js b/static/js/sw.js index 990752a..fa5dff0 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v40'; +const CACHE = 'diora-v41'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', diff --git a/templates/accounts/settings.html b/templates/accounts/settings.html index 2fffad2..69e373a 100644 --- a/templates/accounts/settings.html +++ b/templates/accounts/settings.html @@ -125,6 +125,75 @@ {% endif %} + + +
+

Cloud-Verbindungen für Bücher

+

+ Verbinde einen WebDAV-Server — Nextcloud, ownCloud, Synology oder was auch immer WebDAV + spricht — und importiere .epub- und .pdf-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. +

+

+ Lege dafür bitte ein App-Passwort an (bei Nextcloud unter + Einstellungen → Sicherheit) statt dein Konto-Passwort einzutragen — es wird serverseitig + gespeichert und lässt sich jederzeit einzeln widerrufen. +

+ + {% if webdav_sources %} +
    + {% for source in webdav_sources %} +
  • +
    + {{ source.label }} + {{ source.base_url }}{% if source.root_path %} · /{{ source.root_path }}{% endif %} +
    +
    +
    + {% csrf_token %} + +
    +
    + {% csrf_token %} + +
    +
    +
  • + {% endfor %} +
+ {% endif %} + +
+ {% csrf_token %} + + + + + +

+ 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. +

+ +
+
{% endblock %} diff --git a/templates/radio/player.html b/templates/radio/player.html index 931db3e..5e0248e 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -319,6 +319,16 @@ + +