Bücher: Cloud-Import aus WebDAV/Nextcloud (SW v41)
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:
parent
0de6c186fb
commit
b205625e21
15 changed files with 1498 additions and 18 deletions
|
|
@ -3,3 +3,8 @@ DEBUG=True
|
||||||
AMAZON_AFFILIATE_TAG=diora-20
|
AMAZON_AFFILIATE_TAG=diora-20
|
||||||
LASTFM_API_KEY=
|
LASTFM_API_KEY=
|
||||||
LASTFM_API_SECRET=
|
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
|
||||||
|
|
|
||||||
33
accounts/migrations/0005_webdavsource.py
Normal file
33
accounts/migrations/0005_webdavsource.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import secrets
|
import secrets
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
@ -37,6 +38,57 @@ def save_user_profile(sender, instance, **kwargs):
|
||||||
instance.profile.save()
|
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():
|
def _generate_token():
|
||||||
return secrets.token_hex(32)
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
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
|
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)
|
# No data_ct/data_iv leaked into the snapshot (book bytes stay lazy-fetched)
|
||||||
self.assertNotIn('data_ct', data['books'][0])
|
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')
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,7 @@ urlpatterns = [
|
||||||
path('check-password/', views.check_password, name='check_password'),
|
path('check-password/', views.check_password, name='check_password'),
|
||||||
path('change-password/', views.change_password, name='change_password'),
|
path('change-password/', views.change_password, name='change_password'),
|
||||||
path('api-token/regenerate/', views.regenerate_api_token, name='regenerate_api_token'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth import authenticate, login, get_user_model, update_session_auth_hash
|
from django.contrib.auth import authenticate, login, get_user_model, update_session_auth_hash
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, PasswordChangeForm
|
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, PasswordChangeForm
|
||||||
|
|
@ -11,7 +12,8 @@ from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from radio import lastfm as lastfm_module
|
from radio import lastfm as lastfm_module
|
||||||
|
|
||||||
from .models import ApiToken
|
from .models import ApiToken, WebDAVSource
|
||||||
|
from .webdav import WebDAVError, list_directory
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
@ -75,6 +77,7 @@ def settings_view(request):
|
||||||
'has_lastfm': profile.has_lastfm(),
|
'has_lastfm': profile.has_lastfm(),
|
||||||
'password_form': PasswordChangeForm(request.user),
|
'password_form': PasswordChangeForm(request.user),
|
||||||
'api_token': getattr(request.user, 'api_token', None),
|
'api_token': getattr(request.user, 'api_token', None),
|
||||||
|
'webdav_sources': request.user.webdav_sources.all(),
|
||||||
}
|
}
|
||||||
return render(request, 'accounts/settings.html', context)
|
return render(request, 'accounts/settings.html', context)
|
||||||
|
|
||||||
|
|
@ -213,6 +216,7 @@ def change_password(request):
|
||||||
'password_form': form,
|
'password_form': form,
|
||||||
'password_form_open': True,
|
'password_form_open': True,
|
||||||
'api_token': getattr(request.user, 'api_token', None),
|
'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, _ = ApiToken.objects.get_or_create(user=request.user)
|
||||||
token.regenerate()
|
token.regenerate()
|
||||||
return redirect('settings')
|
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
380
accounts/webdav.py
Normal 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 `<!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).'),
|
||||||
|
)
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import views, webdav
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.book_list, name='book_list'),
|
path('', views.book_list, name='book_list'),
|
||||||
path('upload/', views.upload_book, name='upload_book'),
|
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('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>/data/', views.get_book_data, name='get_book_data'),
|
||||||
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
||||||
|
|
|
||||||
89
books/webdav.py
Normal file
89
books/webdav.py
Normal 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
|
||||||
|
|
@ -109,6 +109,8 @@ BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB
|
||||||
VOLUME_DEFAULT = 204 # out of 255
|
VOLUME_DEFAULT = 204 # out of 255
|
||||||
ITUNES_TIMEOUT = 6 # seconds
|
ITUNES_TIMEOUT = 6 # seconds
|
||||||
BOOK_METADATA_TIMEOUT = 6 # seconds (DNB / Open Library shelf lookup)
|
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
|
PODCAST_INBOX_PAGE_SIZE = 200
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
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_TAG = os.environ.get('AMAZON_AFFILIATE_TAG', 'diora-20')
|
||||||
AMAZON_AFFILIATE_ENABLED = os.environ.get('AMAZON_AFFILIATE_ENABLED', 'True') == 'True'
|
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', '')
|
BUILD_TIME = os.environ.get('BUILD_TIME', '')
|
||||||
|
|
|
||||||
|
|
@ -710,6 +710,161 @@ a:hover {
|
||||||
padding: 1.5rem 0;
|
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
|
RESPONSIVE
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
|
||||||
188
static/js/app.js
188
static/js/app.js
|
|
@ -3776,23 +3776,31 @@ async function deriveAndStoreKey() {
|
||||||
|
|
||||||
async function uploadEbook(file) {
|
async function uploadEbook(file) {
|
||||||
const statusEl = $('book-upload-status');
|
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 (file.size > DIORA_CONFIG.ebookMaxBytes) {
|
||||||
if (statusEl) statusEl.textContent = `File too large (max ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
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…';
|
if (statusEl) statusEl.textContent = 'Encrypting…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buf = await file.arrayBuffer();
|
let title = filename.replace(/\.(epub|pdf)$/i, '');
|
||||||
|
|
||||||
let title = file.name.replace(/\.(epub|pdf)$/i, '');
|
|
||||||
let author = '';
|
let author = '';
|
||||||
let isbn = '';
|
let isbn = '';
|
||||||
const type = isPdf ? 'pdf' : 'epub';
|
const type = isPdf ? 'pdf' : 'epub';
|
||||||
|
|
@ -3821,7 +3829,7 @@ async function uploadEbook(file) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = await getOrCreateEncKey();
|
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([
|
const [metaEnc, dataEnc] = await Promise.all([
|
||||||
encryptBytes(key, metaJson),
|
encryptBytes(key, metaJson),
|
||||||
encryptBytes(key, buf),
|
encryptBytes(key, buf),
|
||||||
|
|
@ -3843,11 +3851,165 @@ async function uploadEbook(file) {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
if (statusEl) statusEl.textContent = `✓ "${title}" uploaded`;
|
if (statusEl) statusEl.textContent = `✓ "${title}" uploaded`;
|
||||||
loadBookList();
|
loadBookList();
|
||||||
} else {
|
return true;
|
||||||
if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed');
|
|
||||||
}
|
}
|
||||||
|
if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed');
|
||||||
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (statusEl) statusEl.textContent = 'Upload failed: ' + e.message;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* 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 PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,75 @@
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,16 @@
|
||||||
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
||||||
<span id="book-upload-status" class="muted"></span>
|
<span id="book-upload-status" class="muted"></span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<label class="book-list-filter">
|
<label class="book-list-filter">
|
||||||
<input type="checkbox" id="book-show-read-toggle" onchange="_onBookShowReadToggle(this.checked)">
|
<input type="checkbox" id="book-show-read-toggle" onchange="_onBookShowReadToggle(this.checked)">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue