2026-08-15 16:20:54 +02:00
|
|
|
import secrets
|
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>
2026-08-28 08:26:22 +00:00
|
|
|
from urllib.parse import urlsplit
|
2026-08-15 16:20:54 +02:00
|
|
|
|
2026-03-16 19:19:22 +01:00
|
|
|
from django.db import models
|
|
|
|
|
from django.contrib.auth.models import User
|
|
|
|
|
from django.db.models.signals import post_save
|
|
|
|
|
from django.dispatch import receiver
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserProfile(models.Model):
|
|
|
|
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
|
|
|
|
lastfm_session_key = models.CharField(max_length=100, blank=True)
|
|
|
|
|
lastfm_username = models.CharField(max_length=100, blank=True)
|
|
|
|
|
lastfm_scrobble = models.BooleanField(default=True)
|
2026-03-19 13:39:59 +01:00
|
|
|
background_image_data = models.TextField(blank=True) # base64 data URL (legacy)
|
|
|
|
|
background_encrypted = models.TextField(blank=True) # base64 AES-GCM ciphertext
|
|
|
|
|
background_iv = models.CharField(max_length=32, blank=True) # hex IV
|
|
|
|
|
background_mime = models.CharField(max_length=30, blank=True) # e.g. 'image/jpeg'
|
|
|
|
|
focus_station_url = models.URLField(max_length=1000, blank=True)
|
|
|
|
|
focus_station_name = models.CharField(max_length=300, blank=True)
|
2026-03-16 19:19:22 +01:00
|
|
|
|
|
|
|
|
def has_lastfm(self) -> bool:
|
|
|
|
|
return bool(self.lastfm_session_key)
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return f"Profile of {self.user.username}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@receiver(post_save, sender=User)
|
|
|
|
|
def create_user_profile(sender, instance, created, **kwargs):
|
|
|
|
|
if created:
|
|
|
|
|
UserProfile.objects.create(user=instance)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@receiver(post_save, sender=User)
|
|
|
|
|
def save_user_profile(sender, instance, **kwargs):
|
|
|
|
|
if hasattr(instance, 'profile'):
|
|
|
|
|
instance.profile.save()
|
2026-08-15 16:20:54 +02:00
|
|
|
|
|
|
|
|
|
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>
2026-08-28 08:26:22 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 16:20:54 +02:00
|
|
|
def _generate_token():
|
|
|
|
|
return secrets.token_hex(32)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ApiToken(models.Model):
|
|
|
|
|
"""Personal access token for local/native clients (see accounts.middleware).
|
|
|
|
|
|
|
|
|
|
Sent as `Authorization: Bearer <token>` and treated identically to a
|
|
|
|
|
session login by ApiTokenAuthMiddleware — grants the same access as the
|
|
|
|
|
owning user across the whole app (books, podcasts, radio)."""
|
|
|
|
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='api_token')
|
|
|
|
|
token = models.CharField(max_length=64, unique=True, db_index=True, default=_generate_token)
|
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
|
|
|
|
|
|
def regenerate(self):
|
|
|
|
|
self.token = _generate_token()
|
|
|
|
|
self.save(update_fields=['token'])
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return f"ApiToken({self.user.username})"
|