diora-web/accounts/models.py
marwin b205625e21
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 52s
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

111 lines
4.4 KiB
Python

import secrets
from urllib.parse import urlsplit
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
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)
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)
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()
class WebDAVSource(models.Model):
"""A user-supplied WebDAV endpoint that ebooks can be imported from.
Deliberately generic rather than Nextcloud-specific — any WebDAV server
(Nextcloud, ownCloud, Synology, rclone serve, …) works, Nextcloud just gets
a URL-shorthand in `normalized_base_url`.
The password is stored in the clear because the server has to replay it on
every PROPFIND/GET (same trade-off as `lastfm_session_key` above). The UI
therefore tells users to create a revocable *app password* rather than
handing over their account password.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='webdav_sources')
label = models.CharField(max_length=100)
base_url = models.URLField(max_length=500)
username = models.CharField(max_length=200, blank=True)
password = models.CharField(max_length=500, blank=True)
root_path = models.CharField(max_length=500, blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"WebDAVSource({self.label}, user={self.user_id})"
def normalized_base_url(self) -> str:
"""Collection root to resolve browse paths against, always ending in '/'.
A bare host ('https://cloud.example.com') is expanded to the Nextcloud
files endpoint, since that is the URL users actually have at hand; an
URL that already points into a DAV tree is left alone so non-Nextcloud
servers stay usable.
"""
url = self.base_url.strip()
if not url.endswith('/'):
url += '/'
# Match against the path only — a host literally named "webdav.…" or
# "dav.…" must not be mistaken for a URL that already points into a
# DAV tree.
path = urlsplit(url).path.lower()
is_dav = any(marker in path for marker in ('/remote.php/', '/dav/', '/webdav'))
if not is_dav and self.username:
url += f'remote.php/dav/files/{self.username}/'
root = self.root_path.strip().strip('/')
if root:
url += root + '/'
return url
def _generate_token():
return secrets.token_hex(32)
class ApiToken(models.Model):
"""Personal access token for local/native clients (see accounts.middleware).
Sent as `Authorization: Bearer <token>` and treated identically to a
session login by ApiTokenAuthMiddleware — grants the same access as the
owning user across the whole app (books, podcasts, radio)."""
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='api_token')
token = models.CharField(max_length=64, unique=True, db_index=True, default=_generate_token)
created_at = models.DateTimeField(auto_now_add=True)
def regenerate(self):
self.token = _generate_token()
self.save(update_fields=['token'])
def __str__(self):
return f"ApiToken({self.user.username})"