Bücher: Cloud-Import aus WebDAV/Nextcloud (SW v41)
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 52s

Verbindungen werden pro Nutzer in den Einstellungen angelegt (Nextcloud,
ownCloud, Synology oder generisches WebDAV) — der Server ist bewusst nicht
auf eine feste Instanz verdrahtet. Im Bücher-Tab lässt sich der entfernte
Ordner durchblättern und eine .epub/.pdf direkt in die Bibliothek ziehen.

Der Download läuft über den Server, weil der WebDAV-Host cross-origin ist
und keine CORS-Header schickt. Verschlüsselt wird trotzdem erst im Browser:
uploadEbook ist in _importEbookBuffer aufgeteilt, das sich lokaler Upload
und Cloud-Import teilen. Gespeichert wird wie bisher nur Geheimtext.

Weil jeder registrierte Nutzer die Ziel-URL bestimmt und diora im Docker-Netz
neben anderen Diensten läuft, ist der Import eine SSRF-Fläche. Dagegen:

  - assert_safe_url weist Hosts ab, die auf nicht-öffentliche Adressen
    auflösen (inkl. NAT64 und IPv4-kompatibler v6-Adressen, die is_global
    durchlässt)
  - _assert_peer_is_safe prüft die tatsächliche Peer-Adresse nach dem
    Verbinden — requests löst den Namen ein zweites Mal auf, sonst wäre der
    Guard per DNS-Rebinding umgehbar
  - Redirects werden abgelehnt statt verfolgt
  - identische Fehlermeldung für "nicht auflösbar" und "privat", ohne die
    IP zu nennen, damit der Endpunkt kein Scanner für interne Dienste wird

Antwort-Bodies laufen durch _read_capped, und DTDs werden vor dem Parsen
abgewiesen: ElementTree expandiert interne Entities, und seit Python 3.12
gibt es XMLParser.parser nicht mehr, um einen Handler zu setzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-08-28 08:26:22 +00:00
parent 0de6c186fb
commit b205625e21
15 changed files with 1498 additions and 18 deletions

View file

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

View file

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

View file

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

View file

@ -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'''<?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

@ -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/<int:pk>/test/', views.webdav_test, name='webdav_test'),
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
]

View file

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

380
accounts/webdav.py Normal file
View file

@ -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 = (
'<?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,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/<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'),

89
books/webdav.py Normal file
View file

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

View file

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

View file

@ -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
========================================================= */

View file

@ -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;
}
}

View file

@ -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',

View file

@ -125,6 +125,75 @@
</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 %}

View file

@ -319,6 +319,16 @@
<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)">