diora-web/accounts/models.py

182 lines
7.9 KiB
Python
Raw Normal View History

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-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)
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()
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='')
# "Eingang" (docs/library.md): new books in it come into the library by themselves —
# a device with the key fetches, encrypts and uploads them (WebDAVSeen keeps which).
inbox = models.BooleanField(default=False)
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
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
class WebDAVSeen(models.Model):
"""A file of an "Eingang" source a device took (or is taking) into the library: taken
files aren't offered again; a claim keeps two devices from taking the same file at once
and runs out when a device didn't finish."""
source = models.ForeignKey(WebDAVSource, on_delete=models.CASCADE, related_name='seen')
path = models.CharField(max_length=1000)
size = models.BigIntegerField(default=0)
modified = models.CharField(max_length=100, blank=True)
claimed_at = models.DateTimeField(null=True, blank=True)
done_at = models.DateTimeField(null=True, blank=True)
result = models.CharField(max_length=20, blank=True) # imported | known | too_big | failed
class Meta:
unique_together = ('source', 'path')
class UserVault(models.Model):
"""Small private settings of the devices, end-to-end encrypted with the book key
(docs/ai.md: the Anthropic API key of the "KI" features). The server keeps the blob,
one per name, and never sees what is in it."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='vault')
name = models.CharField(max_length=40)
ct = models.TextField()
iv = models.CharField(max_length=64)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'name')
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})"
class OidcIdentity(models.Model):
"""An account of the sign-in provider (Authelia) linked to a diora account
(accounts/oidc.py). `sub` is the provider's stable id; name and mail are only shown."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='oidc_identities')
issuer = models.CharField(max_length=200)
sub = models.CharField(max_length=255)
username = models.CharField(max_length=150, blank=True)
email = models.CharField(max_length=254, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
last_login_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ('issuer', 'sub')
def __str__(self):
return f"OidcIdentity({self.username or self.sub} -> {self.user_id})"
class KeyShare(models.Model):
"""A device without the book key asks an unlocked device of the same account for it
(accounts/keyshare.py). The server only passes on public keys and ciphertext: the new
device sends an ECDH public key, the unlocked one answers with its own public key and
the book key encrypted for the shared secret. Both show a code made from the request's
public key, so the user can see the answer goes to the right device."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='key_shares')
token = models.CharField(max_length=64, unique=True, default=_generate_token)
device = models.CharField(max_length=100, blank=True)
request_pub = models.TextField() # base64 raw P-256 point of the asking device
answer_pub = models.TextField(blank=True)
answer_iv = models.CharField(max_length=64, blank=True)
answer_ct = models.TextField(blank=True)
declined = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
polled_at = models.DateTimeField(null=True, blank=True) # the asking device still waits
answered_at = models.DateTimeField(null=True, blank=True)