Compare commits

..

14 commits

Author SHA1 Message Date
marwin
26f983bbaa Default-Lautstärke auf 40% senken
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 1m31s
Betrifft alle Sender, insbesondere creamfresh radio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drq8o4HfFmwkXz2AgvaQ4p
2026-09-04 12:30:58 +02:00
marwin
171560c71e Add thumbs up/down for creamfresh radio, hardcoded to that station
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 1m35s
Relays to the DJ's own /dj/feedback via the same server-side-auth
proxy pattern as the stream itself, so the browser never needs the
station's credentials. Buttons only show when the currently playing
station is creamfresh radio (matched by URL against a hardcoded
constant) -- no other station's backend does anything with a vote,
so no other station gets the buttons yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drq8o4HfFmwkXz2AgvaQ4p
2026-09-04 11:38:34 +02:00
marwin
9228906db8 Add a transparent stream proxy for creamfresh radio
All checks were successful
Build and push Docker image / build (push) Successful in 19s
Test / test (push) Successful in 1m34s
A plain <audio src="https://user:pass@host/..."> doesn't get its
embedded Basic Auth credentials honoured consistently across
browsers (the user hit a native auth prompt on play) -- this instead
holds the credentials server-side and relays the request. Forwards
the client's own Icy-MetaData header and the upstream's icy-* response
headers unchanged, so the existing icy.py-based metadata SSE keeps
working when pointed at this URL instead of the direct one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drq8o4HfFmwkXz2AgvaQ4p
2026-09-04 11:29:52 +02:00
marwin
0fd5461daa Vorlesen: Sprachauswahl DE/EN mit englischer Piper-Stimme (SW v43)
All checks were successful
Build and push Docker image / build (push) Successful in 1m7s
Test / test (push) Successful in 1m32s
Reader-Header bekommt ein #reader-tts-lang-Dropdown (Auswahl, keine
Sprach-Erkennung — persistiert in localStorage); /tts/synthesize/ nimmt jetzt
{"lang": "de"|"en"} entgegen. tts/piper_engine.py lädt pro Sprache eine eigene
PiperVoice lazy (settings.TTS_VOICES), Docker-Image lädt zusätzlich
en_US-lessac-medium beim Build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6yX2S9aTGepYA9JFba9x
2026-09-01 07:04:27 +02:00
marwin
05216a8613 Vorlesen-Funktion im Reader: Piper/Thorsten-TTS (SW v42)
All checks were successful
Build and push Docker image / build (push) Successful in 1m3s
Test / test (push) Successful in 1m32s
Serverseitige Sprachsynthese (deutsche Thorsten-Stimme, Piper) für den
EPUB-Reader: /tts/synthesize/ nimmt einen Satz (max. 500 Zeichen) entgegen,
synthetisiert und streamt WAV zurück, ohne zu persistieren, zu loggen oder zu
cachen — eine bewusste, eng begrenzte Ausnahme vom "Server sieht nie
Klartext"-Prinzip (siehe CLAUDE.md). Der neue ▶-Button im Reader-Header liest
ab der aktuellen Position satzweise vor, hebt den gerade gesprochenen Satz
hervor und scrollt mit; eine kleine Leiste bietet Pause/Stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6yX2S9aTGepYA9JFba9x
2026-08-31 22:29:13 +02:00
marwin
58fad47d90 CI: auf Python 3.12 heben, damit gegen das getestet wird, was ausgeliefert wird
All checks were successful
Test / test (push) Successful in 1m16s
Build and push Docker image / build (push) Successful in 13s
Der Testlauf lief in catthehacker/ubuntu:act-22.04 mit Python 3.10. Django 6.1
verlangt >= 3.12, ließ sich dort also gar nicht installieren — was erst durch
das Pinnen aufgefallen ist: vorher stand "django>=4.2" in requirements.txt und
pip hat in CI still ein 4.x aufgelöst, während das Image auf python:3.12-slim
ein 6.1 bekam. Die Suite hat damit eine andere Django-Version getestet als die
ausgelieferte.

24.04 bringt Python 3.12 mit und markiert sein System-Python als extern
verwaltet (PEP 668), deshalb laufen die Schritte jetzt über ein venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:56:37 +00:00
marwin
23798cda01 Alle Abhängigkeiten pinnen; STATICFILES_STORAGE auf STORAGES umstellen
Some checks failed
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Failing after 6s
requirements.txt stand durchgehend auf ">=", die gebaute Version hing also am
Kalendertag statt am Repo. Genau daran ist der letzte Deploy gescheitert.
Jetzt exakt die Versionen, gegen die die Suite grün ist und die produktiv
laufen — Updates werden damit zu einer bewussten Änderung mit CI-Lauf.

Django bleibt auf 6.1: dorthin ist die Instanz ohnehin schon gedriftet, es
läuft, und 4.2 LTS ist seit April 2026 aus dem Support.

Dabei ist aufgefallen, dass Django 5.1 STATICFILES_STORAGE entfernt hat. Die
Einstellung stand noch da und wurde stillschweigend ignoriert, womit
whitenoise auf StaticFilesStorage zurückfiel und Assets unkomprimiert
auslieferte — app.js mit 251 KB statt 62 KB bei jedem kalten Laden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:50:47 +00:00
marwin
0b62fbc0de Hotfix: gunicorn/packaging pinnen — gevent-Worker startete nicht mehr
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 53s
Der erste Rebuild seit dem 17.08. zog gunicorn 26.2.0, dessen ggevent.py
packaging.version importiert, während das Paket selbst überhaupt keine
Dependencies deklariert ("Requires:" ist leer). Damit fehlte packaging im
Image und gunicorn brach beim Start ab:

    Error: class uri 'gevent' invalid or not found
    ModuleNotFoundError: No module named 'packaging'

Beides wandert nach requirements.txt, und das Dockerfile installiert
gunicorn nicht mehr ungepinnt nebenher — sonst kann der nächste Rebuild den
Worker wieder unter uns austauschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:34:27 +00:00
marwin
b205625e21 Bücher: Cloud-Import aus WebDAV/Nextcloud (SW v41)
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 52s
Verbindungen werden pro Nutzer in den Einstellungen angelegt (Nextcloud,
ownCloud, Synology oder generisches WebDAV) — der Server ist bewusst nicht
auf eine feste Instanz verdrahtet. Im Bücher-Tab lässt sich der entfernte
Ordner durchblättern und eine .epub/.pdf direkt in die Bibliothek ziehen.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:26:22 +00:00
marwin
0de6c186fb Bücher: "Zuletzt gelesen" wieder als normale Listenansicht statt Karten-Leiste (SW v40)
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 24s
Bleibt an der gleichen Stelle (fest oben, unabhängig von Ordner-Navigation und
Lesefilter), nutzt jetzt aber dieselben Listeneinträge wie der Rest der Ansicht
(inkl. Drei-Punkte-Menü) statt der horizontal scrollbaren Karten.

Da ein Buch dadurch gleichzeitig oben bei "Zuletzt gelesen" und unten in der
Ordner-/Gesamtliste gerendert werden kann, darf das Drei-Punkte-Menü nicht mehr
über eine (dann doppelt vergebene) id gesucht werden — toggleBookMenu bekommt
jetzt den geklickten Button und findet sein Menü relativ dazu im DOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 15:37:09 +02:00
marwin
635fd7ef1c Bücher: Titel/Autor-Fallback + manuelle ISBN-Eingabe für Regal-Lookup (SW v39)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 25s
Bisher scheiterte "Metadaten abrufen" komplett, wenn im EPUB keine ISBN steckte
(oder bei PDFs, die nie eine liefern). Der Server versucht jetzt bei fehlendem/
erfolglosem ISBN-Treffer automatisch eine DNB- bzw. Open-Library-Textsuche nach
Titel+Autor (weniger präzise, daher als "dnb-title"/"openlibrary-title" markiert).
Bleibt auch das ohne Treffer, fragt der Client einmalig nach einer manuell
eingetippten ISBN (z.B. vom Buchrücken) und versucht es damit erneut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 12:33:54 +02:00
marwin
e6b3ee620e Bücher: Titel in "Zuletzt gelesen"/Ordner-Kacheln unsichtbar (Button-Farbvererbung); Regal-Lookup jetzt auch für Altbestand (SW v38)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 24s
<button> erbt in den meisten Browsern nicht automatisch color von der Seite —
.book-recent-item und .book-folder-tile hatten keine explizite Farbe gesetzt,
wodurch der Titeltext (kein separates color wie beim gedimmten Autor-Span)
unsichtbar auf dunklem Grund landete.

Der Metadaten-Button funktionierte bisher nur für Bücher, die nach der letzten
Änderung hochgeladen wurden (ISBN wurde nur dort extrahiert). Fehlt die ISBN im
Meta-Blob, wird sie jetzt bei Bedarf direkt aus der bereits clientseitig
entschlüsselbaren Buchdatei nachgeholt (gleiche OPF-Quelle wie beim Öffnen) und
für künftige Abrufe mitgespeichert — kein erneuter Upload nötig. Nur EPUB, PDFs
liefern über pdf.js keine verlässliche ISBN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 12:12:17 +02:00
marwin
3e301b6f61 Bücher: Regal-Metadaten-Lookup (DNB → Open Library) + Drei-Punkte-Menü (SW v37)
All checks were successful
Build and push Docker image / build (push) Successful in 20s
Test / test (push) Successful in 34s
Manuell per neuem "Metadaten"-Menüpunkt ausgelöst (nie automatisch): der Server
schlägt die aus dem EPUB extrahierte ISBN zuerst bei der DNB (SRU, DDC-Sachgruppe),
dann bei Open Library nach und gibt nur ein kurzes Label zurück, ohne etwas zu
speichern — das Label landet als Badge nur im ohnehin verschlüsselten Meta-Blob des
Clients. Das ist eine bewusste, eng begrenzte Ausnahme vom "Server sieht nie
Buchinhalte"-Prinzip: die Verschlüsselung dient vor allem der Absicherung gegen
Piraterie-Vorwürfe, nicht striktem Zero-Knowledge gegenüber dem eigenen Server.

Da pro Buch jetzt Reparieren/Herunterladen/Ordner/Metadaten/Lesestatus/Löschen
zusammenkommen, wandern alle Aktionen außer "Open" in ein Drei-Punkte-Menü.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 11:27:16 +02:00
marwin
7d99e58286 Bücher: Ordner (eine Ebene) + "Zuletzt gelesen"-Leiste (SW v36)
All checks were successful
Build and push Docker image / build (push) Successful in 22s
Test / test (push) Successful in 34s
Ordnername lebt im bereits verschlüsselten meta-Blob (wie Titel/Autor),
daher keine Schema-Änderung nötig — nur ein schlanker Endpoint zum
Aktualisieren von meta_ct/meta_iv ohne die Buchdaten neu hochzuladen.
Die Bücheransicht zeigt jetzt immer oben die letzten 7 gelesenen Titel
als Shortcut-Leiste, darunter Ordner-Kacheln (root) bzw. den Inhalt
eines geöffneten Ordners (kein Verschachteln).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 10:58:53 +02:00
48 changed files with 2771 additions and 1610 deletions

View file

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

View file

@ -11,16 +11,25 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-22.04 # 22.04 ships Python 3.10, which cannot install the pinned Django (it
# needs >= 3.12). While requirements.txt still said "django>=4.2" that
# went unnoticed and pip quietly resolved a 4.x here — so CI was testing
# a different Django than the image actually shipped. 24.04 gives us
# 3.12, matching the Dockerfile's python:3.12-slim.
image: catthehacker/ubuntu:act-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Python dependencies - name: Install Python dependencies
run: pip install --no-cache-dir -r requirements.txt # 24.04 marks its system Python as externally managed (PEP 668), so a
# bare `pip install` is refused. A venv is the sanctioned way in.
run: |
python -m venv /tmp/venv
/tmp/venv/bin/pip install --no-cache-dir -r requirements.txt
- name: Django system check - name: Django system check
run: python manage.py check run: /tmp/venv/bin/python manage.py check
- name: Run tests - name: Run tests
run: python manage.py test run: /tmp/venv/bin/python manage.py test

1
.gitignore vendored
View file

@ -19,6 +19,7 @@ env/
media/ media/
staticfiles/ staticfiles/
.env .env
tts_models/
# IDE # IDE
.idea/ .idea/

View file

@ -4,10 +4,26 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \
curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn RUN pip install --no-cache-dir -r requirements.txt
# Piper voices for the reader's read-aloud feature (see tts/piper_engine.py).
# Fetched at build time rather than kept in git or bind-mounted — there's no
# existing volume-mount pattern for extra binary assets in this repo, and
# baking them into the image keeps the watchtower "just pull the new image"
# deploy flow working unchanged.
RUN mkdir -p /app/tts_models && \
curl -fsSL -o /app/tts_models/de_DE-thorsten-medium.onnx \
https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx && \
curl -fsSL -o /app/tts_models/de_DE-thorsten-medium.onnx.json \
https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx.json && \
curl -fsSL -o /app/tts_models/en_US-lessac-medium.onnx \
https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx && \
curl -fsSL -o /app/tts_models/en_US-lessac-medium.onnx.json \
https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json
ARG BUILD_TIME ARG BUILD_TIME
ENV BUILD_TIME=${BUILD_TIME} ENV BUILD_TIME=${BUILD_TIME}

View file

@ -0,0 +1,33 @@
# Generated by Django 6.1 on 2026-08-28 08:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_apitoken'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WebDAVSource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.CharField(max_length=100)),
('base_url', models.URLField(max_length=500)),
('username', models.CharField(blank=True, max_length=200)),
('password', models.CharField(blank=True, max_length=500)),
('root_path', models.CharField(blank=True, default='', max_length=500)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webdav_sources', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
]

View file

@ -1,4 +1,5 @@
import secrets 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)

View file

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

View file

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

View file

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

@ -0,0 +1,380 @@
"""Minimal, hardened WebDAV client behind the ebook cloud import.
Only PROPFIND (Depth 1) and GET are implemented enough to browse a remote
folder and pull a single file out of it.
Every request here is made by the server to a URL the *user* supplied, which
makes this an SSRF surface: registration is open (accounts/views.py:23) and
diora normally runs in a Docker network next to other services. Three things
guard it, and all three are load-bearing:
* `assert_safe_url` refuses hosts that resolve to non-public addresses,
* `_assert_peer_is_safe` re-checks the address we *actually* connected to,
because requests resolves the hostname a second time and a hostile resolver
can answer differently across the two lookups (DNS rebinding),
* redirects are refused rather than followed, so a public host cannot bounce
us onto a private one.
Residual risk worth knowing about: a rebinding attacker can still cause a
single request to be *sent* to an internal address; the peer check runs before
the body is read, so nothing comes back to them. Fully closing that needs
connect-time DNS pinning, which requests does not expose without reaching into
urllib3.
Response bodies are read through `_read_capped` and parsed only after any DTD
is refused the remote server is attacker-chosen, so an unbounded read or a
billion-laughs document would otherwise be a cheap way to OOM the container.
"""
import ipaddress
import socket
import xml.etree.ElementTree as ET
from urllib.parse import quote, unquote, urljoin, urlsplit
import requests
from django.conf import settings
DAV_NS = '{DAV:}'
BOOK_EXTENSIONS = ('.epub', '.pdf')
# A directory listing is XML; anything this large is not a real one.
MAX_LISTING_BYTES = 8 * 1024 * 1024
class WebDAVError(Exception):
"""Failure with a message that is safe to show the user directly."""
class WebDAVInputError(WebDAVError):
"""The user's own URL/path/credentials are at fault, not the remote server.
Separated so views can answer 400 instead of 502 a rejected traversal
attempt is not an upstream outage.
"""
def _timeout():
return getattr(settings, 'WEBDAV_TIMEOUT', 15)
def _allow_private():
return getattr(settings, 'WEBDAV_ALLOW_PRIVATE_HOSTS', False)
# ---------------------------------------------------------------------------
# SSRF guard
# ---------------------------------------------------------------------------
def _unreachable(host):
"""One message for every "we won't talk to this" case.
Deliberately does not say whether the name failed to resolve or resolved to
a private address, and never echoes the resolved IP: telling those apart
would turn this endpoint into a scanner for the Docker network's service
names and addresses.
"""
return WebDAVInputError(
f'{host}“ ist nicht öffentlich erreichbar. Aus Sicherheitsgründen sind nur '
'öffentlich auflösbare Server erlaubt.'
)
def _is_public(raw_address):
"""True only for addresses we are willing to open a connection to."""
try:
# Scope IDs ('fe80::1%eth0') are not part of the address itself.
ip = ipaddress.ip_address(raw_address.split('%')[0])
except ValueError:
return False
if not ip.is_global:
return False
# is_global misses two IPv6 transition ranges that can carry an embedded
# private IPv4 address: the well-known NAT64 prefix (RFC 6052) and the
# deprecated IPv4-compatible range.
if ip.version == 6:
for unsafe in ('64:ff9b::/96', '::/96'):
if ip in ipaddress.ip_network(unsafe):
return False
return True
def assert_safe_url(url):
"""Reject URLs that don't resolve to a public address.
Self-hosted setups that legitimately want a LAN target can opt out with
WEBDAV_ALLOW_PRIVATE_HOSTS=True, which is why this is a setting and not a
hard rule.
"""
parts = urlsplit(url)
if parts.scheme not in ('http', 'https'):
raise WebDAVInputError('Nur http:// und https:// werden unterstützt.')
host = parts.hostname
if not host:
raise WebDAVInputError('Die URL enthält keinen Hostnamen.')
try:
port = parts.port or (443 if parts.scheme == 'https' else 80)
except ValueError:
# urlsplit validates the port lazily, on attribute access.
raise WebDAVInputError('Die URL enthält einen ungültigen Port.')
if _allow_private():
return
try:
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
except socket.gaierror:
raise _unreachable(host)
for info in infos:
if not _is_public(info[4][0]):
raise _unreachable(host)
def _assert_peer_is_safe(response):
"""Re-check the address the socket actually connected to.
`assert_safe_url` validates DNS before the request, but requests resolves
the name again when it opens the connection. A resolver answering
public-then-private across those two lookups would otherwise hand us an
internal service's response body.
"""
if _allow_private():
return
sock = getattr(getattr(response.raw, '_connection', None), 'sock', None)
if sock is None:
return # nothing to introspect (mocked, or already released)
try:
peer = sock.getpeername()[0]
except OSError:
return
if not _is_public(peer):
raise WebDAVInputError('Der Server ist nicht öffentlich erreichbar.')
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
def safe_rel_path(raw):
"""Normalise a client-supplied path and keep it inside the source root.
Whatever survives here is percent-encoded by `_build_url` with `quote()`'s
default `safe='/'`, and that is what stops double-encoded traversal
('%252e%252e%252f') and smuggled absolute URLs: '%' and ':' both get
escaped, so they land as literal filename characters. Widening that safe
set would reopen those paths.
"""
raw = (raw or '').strip().strip('/')
if not raw:
return ''
segments = []
for segment in raw.split('/'):
if not segment or segment == '.':
continue
if segment == '..':
raise WebDAVInputError('Ungültiger Pfad.')
segments.append(segment)
return '/'.join(segments)
def _build_url(source, rel_path):
base = source.normalized_base_url()
if not rel_path:
return base
return base + quote(rel_path)
def _request(method, url, source, **kwargs):
assert_safe_url(url)
auth = (source.username, source.password) if source.username else None
try:
# Always streamed: it keeps the connection open long enough to inspect
# the peer, and stops requests from buffering an unbounded body before
# we get the chance to cap it.
response = requests.request(
method, url, auth=auth, timeout=_timeout(),
allow_redirects=False, stream=True, **kwargs
)
except requests.Timeout:
raise WebDAVError('Zeitüberschreitung beim Server.')
except requests.RequestException as exc:
raise WebDAVError(f'Verbindung fehlgeschlagen: {exc.__class__.__name__}')
try:
_assert_peer_is_safe(response)
if response.status_code in (301, 302, 303, 307, 308):
raise WebDAVError(
'Der Server hat eine Weiterleitung geschickt. '
'Bitte trage die Ziel-URL direkt ein.'
)
if response.status_code in (401, 403):
raise WebDAVInputError('Anmeldung abgelehnt — Benutzername oder App-Passwort prüfen.')
if response.status_code == 404:
raise WebDAVError('Pfad auf dem Server nicht gefunden.')
if response.status_code >= 400:
raise WebDAVError(f'Server antwortete mit HTTP {response.status_code}.')
except Exception:
response.close()
raise
return response
def _read_capped(response, max_bytes, oversize_error):
"""Read a streamed body, aborting as soon as it exceeds `max_bytes`."""
declared = response.headers.get('Content-Length')
if declared and declared.isdigit() and int(declared) > max_bytes:
response.close()
raise oversize_error()
chunks = []
total = 0
try:
for chunk in response.iter_content(chunk_size=64 * 1024):
total += len(chunk)
if total > max_bytes:
raise oversize_error()
chunks.append(chunk)
except requests.RequestException:
raise WebDAVError('Übertragung abgebrochen.')
finally:
response.close()
return b''.join(chunks)
# ---------------------------------------------------------------------------
# PROPFIND / GET
# ---------------------------------------------------------------------------
_PROPFIND_BODY = (
'<?xml version="1.0" encoding="utf-8"?>'
'<d:propfind xmlns:d="DAV:"><d:prop>'
'<d:resourcetype/><d:getcontentlength/><d:getlastmodified/>'
'</d:prop></d:propfind>'
)
def _parse_listing_xml(payload):
"""Parse a multistatus body, refusing any document that carries a DTD.
The remote server is chosen by the user, so a billion-laughs document is
within an attacker's reach, and ElementTree *does* expand internal entities
(verified on 3.12: a 3-level document expands). Installing an expat
EntityDeclHandler is not an option either `XMLParser.parser` no longer
exists in 3.12, so setting it silently does nothing.
That leaves rejecting the DTD before parsing. It costs nothing: a real
multistatus never has one, and a filename containing this text would arrive
escaped as `&lt;!DOCTYPE`, so these bytes can only ever be markup.
"""
lowered = payload.lower()
if b'<!doctype' in lowered or b'<!entity' in lowered:
raise WebDAVError('Die Antwort enthält eine DTD und wurde abgelehnt.')
try:
return ET.fromstring(payload)
except (ET.ParseError, ValueError):
raise WebDAVError('Unerwartete Antwort — ist das wirklich eine WebDAV-URL?')
def _select_prop(node):
"""Return the <prop> block that actually applies.
RFC 4918 lets a server split its answer across several <propstat> blocks
typically a 200 for the properties it found and a 404 for the ones it did
not, in no guaranteed order. Taking the first one blindly mislabels
directories (whose getcontentlength is the missing property) as files, and
the client then filters them out of the listing entirely.
"""
fallback = None
for propstat in node.findall(f'{DAV_NS}propstat'):
prop = propstat.find(f'{DAV_NS}prop')
if prop is None:
continue
if ' 200 ' in (propstat.findtext(f'{DAV_NS}status') or ''):
return prop
if fallback is None:
fallback = prop
return fallback
def list_directory(source, rel_path=''):
"""PROPFIND Depth 1, returned as entries relative to the source root."""
rel_path = safe_rel_path(rel_path)
url = _build_url(source, rel_path)
if not url.endswith('/'):
url += '/'
response = _request(
'PROPFIND', url, source,
headers={'Depth': '1', 'Content-Type': 'application/xml; charset=utf-8'},
data=_PROPFIND_BODY.encode('utf-8'),
)
payload = _read_capped(
response, MAX_LISTING_BYTES,
lambda: WebDAVError('Verzeichnisliste ist unerwartet groß.'),
)
root = _parse_listing_xml(payload)
base_path = unquote(urlsplit(url).path)
entries = []
for node in root.findall(f'{DAV_NS}response'):
href = node.findtext(f'{DAV_NS}href') or ''
# hrefs come as absolute URLs, absolute paths, or relative references.
href_path = unquote(urlsplit(urljoin(url, href)).path)
if not href_path.startswith(base_path):
continue
remainder = href_path[len(base_path):].strip('/')
if not remainder:
continue # the collection we asked about
if '/' in remainder:
continue # Depth 1 should not return these, but be strict anyway
prop = _select_prop(node)
is_dir = (
prop is not None
and prop.find(f'{DAV_NS}resourcetype/{DAV_NS}collection') is not None
)
size_text = prop.findtext(f'{DAV_NS}getcontentlength') if prop is not None else None
try:
size = int(size_text) if size_text else 0
except ValueError:
size = 0
entries.append({
'name': remainder,
'path': f'{rel_path}/{remainder}' if rel_path else remainder,
'is_dir': is_dir,
'size': size,
'modified': (prop.findtext(f'{DAV_NS}getlastmodified') or '') if prop is not None else '',
'is_book': (not is_dir) and remainder.lower().endswith(BOOK_EXTENSIONS),
})
entries.sort(key=lambda e: (not e['is_dir'], e['name'].lower()))
return entries
def fetch_file(source, rel_path, max_bytes):
"""Download one file, refusing anything larger than `max_bytes`.
Reads into memory rather than to disk: the caller hands the bytes straight
back to the browser, which encrypts them. Nothing readable is persisted
server-side.
"""
rel_path = safe_rel_path(rel_path)
if not rel_path:
raise WebDAVInputError('Kein Pfad angegeben.')
if not rel_path.lower().endswith(BOOK_EXTENSIONS):
raise WebDAVInputError('Nur .epub- und .pdf-Dateien können importiert werden.')
response = _request('GET', _build_url(source, rel_path), source)
return _read_capped(
response, max_bytes,
lambda: WebDAVInputError(f'Datei ist zu groß (max. {max_bytes // 1024 // 1024} MB).'),
)

View file

@ -1,12 +1,17 @@
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('<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'),
path('<int:pk>/read/', views.set_book_read, name='set_book_read'), path('<int:pk>/read/', views.set_book_read, name='set_book_read'),
path('<int:pk>/meta/', views.update_book_meta, name='update_book_meta'),
path('<int:pk>/replace-data/', views.replace_book_data, name='replace_book_data'), path('<int:pk>/replace-data/', views.replace_book_data, name='replace_book_data'),
path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'), path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'),
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'), path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),

View file

@ -1,7 +1,9 @@
import base64 import base64
import json import json
import re import re
import xml.etree.ElementTree as ET
import requests
from django.conf import settings from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -16,6 +18,218 @@ def _require_auth(request):
return None return None
# ---------------------------------------------------------------------------
# Library-shelf metadata lookup (DNB, falling back to Open Library)
#
# Only ever called on explicit user request (the "Metadaten" book action), never
# automatically — the server briefly sees the plaintext ISBN for this one proxied
# request, which is a deliberate, narrow exception to the "server never sees book
# content" rule (see CLAUDE.md), made because the encryption's real purpose here is
# to keep the operator from being able to see what's on the platform (piracy
# liability), not strict user privacy — an ISBN lookup against public library
# catalogs doesn't undermine that. Nothing from this lookup is persisted server-side;
# the resulting label is stored only in the client's encrypted meta blob.
# ---------------------------------------------------------------------------
# DNB "Sachgruppen der Deutschen Nationalbibliografie" / DDC divisions (hundreds -> tens),
# used to turn a raw DDC notation like "833.92" into a short shelf label.
_DDC_DIVISIONS = {
'000': 'Allgemeines, Informatik', '010': 'Bibliografien', '020': 'Bibliotheks- und Informationswissenschaft',
'030': 'Enzyklopädien', '050': 'Zeitschriften, fortlaufende Sammelwerke', '060': 'Organisationen, Museumswissenschaft',
'070': 'Nachrichtenmedien, Journalismus, Verlagswesen', '090': 'Handschriften, seltene Bücher',
'100': 'Philosophie', '130': 'Parapsychologie, Okkultismus', '150': 'Psychologie',
'200': 'Religion', '230': 'Christentum, Christliche Theologie', '290': 'Andere Religionen',
'300': 'Sozialwissenschaften, Soziologie', '310': 'Statistiken', '320': 'Politikwissenschaft',
'330': 'Wirtschaft', '340': 'Recht', '350': 'Öffentliche Verwaltung, Militärwissenschaft',
'360': 'Soziale Probleme, Sozialdienste, Versicherungen', '370': 'Erziehung, Schul- und Bildungswesen',
'380': 'Handel, Kommunikation, Verkehr', '390': 'Gebräuche, Etikette, Folklore',
'400': 'Sprache, Linguistik', '420': 'Englisch', '430': 'Deutsch, Germanische Sprachen',
'440': 'Französisch, Romanische Sprachen', '450': 'Italienisch, Rumänisch, Rätoromanisch',
'460': 'Spanisch, Portugiesisch', '470': 'Latein, Italische Sprachen', '480': 'Griechisch', '490': 'Andere Sprachen',
'500': 'Naturwissenschaften', '510': 'Mathematik', '520': 'Astronomie', '530': 'Physik', '540': 'Chemie',
'550': 'Geowissenschaften', '560': 'Paläontologie', '570': 'Biowissenschaften, Biologie',
'580': 'Pflanzen (Botanik)', '590': 'Tiere (Zoologie)',
'600': 'Technik', '610': 'Medizin, Gesundheit', '620': 'Ingenieurwissenschaften', '630': 'Landwirtschaft',
'640': 'Hauswirtschaft', '650': 'Management', '660': 'Chemische Technik', '670': 'Industrielle Fertigung',
'680': 'Fertigung für spezielle Zwecke', '690': 'Hausbau, Bauhandwerk',
'700': 'Künste', '710': 'Landschaftsgestaltung, Raumplanung', '720': 'Architektur',
'730': 'Plastik, Keramik, Metallkunst', '740': 'Zeichnung, angewandte Kunst', '750': 'Malerei',
'760': 'Grafik, Druckgrafik, Fotografie', '780': 'Musik', '790': 'Freizeit, Darstellende Kunst, Sport',
'800': 'Literatur', '810': 'Amerikanische Literatur', '820': 'Englische Literatur',
'830': 'Deutsche Literatur', '840': 'Französische Literatur', '850': 'Italienische Literatur',
'860': 'Spanische, Portugiesische Literatur', '870': 'Lateinische Literatur', '880': 'Griechische Literatur',
'890': 'Literaturen in anderen Sprachen',
'900': 'Geschichte', '910': 'Geografie, Reisen', '920': 'Biografie, Genealogie',
'930': 'Geschichte des Altertums', '940': 'Geschichte Europas', '950': 'Geschichte Asiens',
'960': 'Geschichte Afrikas', '970': 'Geschichte Nordamerikas', '980': 'Geschichte Südamerikas',
'990': 'Geschichte der übrigen Welt',
}
def _ddc_label(ddc_raw):
"""Coarsen a DDC notation ('833.92') down to its nearest known division/class label."""
digits = re.sub(r'\D', '', ddc_raw or '')
if len(digits) < 3:
return None
tens = digits[:2] + '0'
if tens in _DDC_DIVISIONS:
return _DDC_DIVISIONS[tens]
return _DDC_DIVISIONS.get(digits[0] + '00')
_MARC_NS = '{http://www.loc.gov/MARC21/slim}'
def _lookup_dnb_shelf(isbn):
url = 'https://services.dnb.de/sru/dnb'
params = {
'version': '1.1',
'operation': 'searchRetrieve',
'query': f'dnb.num={isbn}',
'recordSchema': 'MARC21-xml',
'maximumRecords': '1',
}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
root = ET.fromstring(resp.content)
for datafield in root.iter(f'{_MARC_NS}datafield'):
if datafield.get('tag') != '082':
continue
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
if subfield.get('code') == 'a' and subfield.text:
label = _ddc_label(subfield.text)
if label:
return label
return None
def _lookup_openlibrary_shelf(isbn):
url = 'https://openlibrary.org/api/books'
params = {'bibkeys': f'ISBN:{isbn}', 'jscmd': 'data', 'format': 'json'}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
data = resp.json().get(f'ISBN:{isbn}', {})
ddc = (data.get('classifications') or {}).get('dewey_decimal_class') or []
if ddc:
label = _ddc_label(ddc[0])
if label:
return label
subjects = data.get('subjects') or []
if subjects:
return subjects[0].get('name')
return None
def _cql_phrase(value):
# CQL string literals are quoted; strip embedded quotes rather than escaping them
# (this only feeds a lookup heuristic, not a stored/displayed value).
return value.replace('"', ' ').strip()
def _lookup_dnb_shelf_by_title(title, author):
url = 'https://services.dnb.de/sru/dnb'
query = f'dnb.tit="{_cql_phrase(title)}"'
if author:
query += f' and dnb.per="{_cql_phrase(author)}"'
params = {
'version': '1.1',
'operation': 'searchRetrieve',
'query': query,
'recordSchema': 'MARC21-xml',
'maximumRecords': '1',
}
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
root = ET.fromstring(resp.content)
for datafield in root.iter(f'{_MARC_NS}datafield'):
if datafield.get('tag') != '082':
continue
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
if subfield.get('code') == 'a' and subfield.text:
label = _ddc_label(subfield.text)
if label:
return label
return None
def _lookup_openlibrary_shelf_by_title(title, author):
url = 'https://openlibrary.org/search.json'
params = {'title': title, 'limit': 1, 'fields': 'ddc,subject'}
if author:
params['author'] = author
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
resp.raise_for_status()
docs = resp.json().get('docs') or []
if not docs:
return None
doc = docs[0]
ddc = doc.get('ddc') or []
if ddc:
label = _ddc_label(ddc[0])
if label:
return label
subjects = doc.get('subject') or []
if subjects:
return subjects[0]
return None
@require_http_methods(['GET'])
def lookup_book_metadata(request):
err = _require_auth(request)
if err:
return err
isbn_raw = request.GET.get('isbn', '').strip()
title = request.GET.get('title', '').strip()
author = request.GET.get('author', '').strip()
isbn = re.sub(r'[^0-9Xx]', '', isbn_raw)
if isbn_raw and len(isbn) not in (10, 13):
return JsonResponse({'error': 'invalid ISBN'}, status=400)
if not isbn and not title:
return JsonResponse({'error': 'isbn or title required'}, status=400)
label = None
source = None
# ISBN is the precise path — try it first when we have one.
if isbn:
try:
label = _lookup_dnb_shelf(isbn)
if label:
source = 'dnb'
except Exception:
pass
if not label:
try:
label = _lookup_openlibrary_shelf(isbn)
if label:
source = 'openlibrary'
except Exception:
pass
# No ISBN (or it drew a blank) — fall back to a title/author text search. Less
# precise (wrong edition/translation is possible), so the source is tagged
# distinctly for the client to hint at that if it wants to.
if not label and title:
try:
label = _lookup_dnb_shelf_by_title(title, author)
if label:
source = 'dnb-title'
except Exception:
pass
if not label:
try:
label = _lookup_openlibrary_shelf_by_title(title, author)
if label:
source = 'openlibrary-title'
except Exception:
pass
return JsonResponse({'label': label, 'source': source})
def _anchor_parts(anchor): def _anchor_parts(anchor):
"""Split a position anchor 'blockIndex:innerFraction' into (block, inner). """Split a position anchor 'blockIndex:innerFraction' into (block, inner).
@ -89,6 +303,35 @@ def set_book_read(request, pk):
return JsonResponse({'ok': True, 'is_read': book.is_read}) return JsonResponse({'ok': True, 'is_read': book.is_read})
@csrf_exempt
@require_http_methods(['POST'])
def update_book_meta(request, pk):
"""Update only the encrypted metadata blob (e.g. to assign a folder) without touching book bytes."""
err = _require_auth(request)
if err:
return err
try:
book = EBook.objects.get(pk=pk, user=request.user)
except EBook.DoesNotExist:
return JsonResponse({'error': 'not found'}, status=404)
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
if not meta_ct or not meta_iv:
return JsonResponse({'error': 'meta_ct, meta_iv required'}, status=400)
book.meta_ct = meta_ct
book.meta_iv = meta_iv
book.save(update_fields=['meta_ct', 'meta_iv'])
return JsonResponse({'ok': True})
@csrf_exempt @csrf_exempt
@require_http_methods(['POST']) @require_http_methods(['POST'])
def upload_book(request): def upload_book(request):

89
books/webdav.py Normal file
View file

@ -0,0 +1,89 @@
"""Cloud import endpoints for the ebook library.
These let the browser browse a user-configured WebDAV server and pull one book
out of it. The fetch is proxied through the server rather than done in the
browser because the WebDAV host is a different origin and will not send CORS
headers for it.
That proxy is a deliberate, narrow relaxation of the same rule the ISBN lookup
in books/views.py bends: the server sees the file's bytes in memory for the
duration of one request, but never persists them. The book is encrypted in the
browser and only then POSTed to /books/upload/ as ciphertext, exactly like a
local upload nothing readable is ever stored server-side.
"""
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from accounts.models import WebDAVSource
from accounts.webdav import WebDAVError, WebDAVInputError, list_directory, fetch_file
from .views import _require_auth
def _get_source(request, pk):
return WebDAVSource.objects.filter(pk=pk, user=request.user).first()
@require_http_methods(['GET'])
def cloud_sources(request):
unauthorized = _require_auth(request)
if unauthorized:
return unauthorized
sources = request.user.webdav_sources.all()
return JsonResponse({'sources': [
{'id': s.id, 'label': s.label, 'host': s.base_url}
for s in sources
]})
@require_http_methods(['GET'])
def cloud_browse(request, pk):
unauthorized = _require_auth(request)
if unauthorized:
return unauthorized
source = _get_source(request, pk)
if not source:
return JsonResponse({'error': 'unknown source'}, status=404)
path = request.GET.get('path', '')
try:
entries = list_directory(source, path)
except WebDAVInputError as exc:
return JsonResponse({'error': str(exc)}, status=400)
except WebDAVError as exc:
return JsonResponse({'error': str(exc)}, status=502)
source.last_used_at = timezone.now()
source.save(update_fields=['last_used_at'])
return JsonResponse({'ok': True, 'path': path, 'entries': entries})
@require_http_methods(['GET'])
def cloud_fetch(request, pk):
unauthorized = _require_auth(request)
if unauthorized:
return unauthorized
source = _get_source(request, pk)
if not source:
return JsonResponse({'error': 'unknown source'}, status=404)
path = request.GET.get('path', '')
try:
payload = fetch_file(source, path, settings.EBOOK_MAX_BYTES)
except WebDAVInputError as exc:
return JsonResponse({'error': str(exc)}, status=400)
except WebDAVError as exc:
return JsonResponse({'error': str(exc)}, status=502)
response = HttpResponse(payload, content_type='application/octet-stream')
# These are the book's plaintext bytes, on their way to be encrypted in the
# browser. Keeping them out of the HTTP disk cache (and any reverse proxy
# in front) is what makes "nothing readable is stored" true end to end.
response['Cache-Control'] = 'no-store'
return response

View file

@ -30,6 +30,7 @@ INSTALLED_APPS = [
'podcasts', 'podcasts',
'books', 'books',
'gpodder', 'gpodder',
'tts',
] ]
EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
@ -97,7 +98,23 @@ STATIC_URL = '/static/'
STATICFILES_DIRS = [BASE_DIR / 'static'] STATICFILES_DIRS = [BASE_DIR / 'static']
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' # Django 5.1 removed STATICFILES_STORAGE in favour of STORAGES. The old setting
# sat here being silently ignored, so whitenoise fell back to plain
# StaticFilesStorage and served everything uncompressed — app.js went out at
# 251 KB instead of 62 KB on every cold load.
#
# The hashed filenames this also generates are inert: no template uses
# {% static %}, they all hardcode /static/… paths. Cache busting comes from the
# service worker's CACHE version instead (static/js/sw.js), which is why it is
# bumped on each release.
STORAGES = {
'default': {
'BACKEND': 'django.core.files.storage.FileSystemStorage',
},
'staticfiles': {
'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage',
},
}
MEDIA_URL = '/media/' MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media'
@ -106,8 +123,11 @@ BG_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
HIGHLIGHTS_MAX_BYTES = 700 * 1024 # 700 KB HIGHLIGHTS_MAX_BYTES = 700 * 1024 # 700 KB
BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB
VOLUME_DEFAULT = 204 # out of 255 VOLUME_DEFAULT = 102 # out of 255 (40%)
ITUNES_TIMEOUT = 6 # seconds ITUNES_TIMEOUT = 6 # seconds
BOOK_METADATA_TIMEOUT = 6 # seconds (DNB / Open Library shelf lookup)
WEBDAV_TIMEOUT = 15 # seconds (cloud import browse/fetch)
WEBDAV_MAX_SOURCES_PER_USER = 10
PODCAST_INBOX_PAGE_SIZE = 200 PODCAST_INBOX_PAGE_SIZE = 200
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
@ -124,4 +144,18 @@ 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', '')
# Piper voice models for the reader's read-aloud feature (see tts/piper_engine.py).
TTS_VOICES = {
'de': os.environ.get(
'TTS_MODEL_PATH_DE', str(BASE_DIR / 'tts_models' / 'de_DE-thorsten-medium.onnx')),
'en': os.environ.get(
'TTS_MODEL_PATH_EN', str(BASE_DIR / 'tts_models' / 'en_US-lessac-medium.onnx')),
}

View file

@ -11,6 +11,7 @@ urlpatterns = [
path('accounts/', include('accounts.urls')), path('accounts/', include('accounts.urls')),
path('podcasts/', include('podcasts.urls')), path('podcasts/', include('podcasts.urls')),
path('books/', include('books.urls')), path('books/', include('books.urls')),
path('tts/', include('tts.urls')),
path('api/2/', include('gpodder.urls')), path('api/2/', include('gpodder.urls')),
path('api/sync/', sync_snapshot, name='api_sync'), path('api/sync/', sync_snapshot, name='api_sync'),
# Served at the root (not /static/js/sw.js) so its default scope covers # Served at the root (not /static/js/sw.js) so its default scope covers

View file

@ -19,4 +19,6 @@ urlpatterns = [
path('radio/focus/record/', views.record_focus_session, name='record_focus_session'), path('radio/focus/record/', views.record_focus_session, name='record_focus_session'),
path('radio/focus/stats/', views.focus_stats, name='focus_stats'), path('radio/focus/stats/', views.focus_stats, name='focus_stats'),
path('radio/stream-player/', views.stream_player, name='stream_player'), path('radio/stream-player/', views.stream_player, name='stream_player'),
path('radio/creamfresh-stream/', views.creamfresh_stream, name='creamfresh_stream'),
path('radio/creamfresh-feedback/', views.creamfresh_feedback, name='creamfresh_feedback'),
] ]

View file

@ -581,6 +581,74 @@ def import_m3u(request):
# Minimal HTTP stream player (standalone tab for mixed-content streams) # Minimal HTTP stream player (standalone tab for mixed-content streams)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# creamfresh radio proxy
# ---------------------------------------------------------------------------
# Held server-side only -- never exposed to the browser this way. The
# upstream is protected by HTTP Basic Auth; a plain <audio src="user:pass@..">
# doesn't get credentials honoured consistently across browsers, so this
# transparently relays the request instead (including the client's own
# Icy-MetaData header, so the existing icy.py-based metadata SSE keeps
# working unmodified when pointed at this URL instead of the direct one).
CREAMFRESH_STREAM_URL = 'https://radio.creamfresh.xyz/stream.mp3'
CREAMFRESH_AUTH = ('player', 'MJMr58p83zAU5zZaDGU0_BIn')
def creamfresh_stream(request):
headers = {}
if request.META.get('HTTP_ICY_METADATA'):
headers['Icy-MetaData'] = request.META['HTTP_ICY_METADATA']
try:
upstream = requests.get(
CREAMFRESH_STREAM_URL,
auth=CREAMFRESH_AUTH,
headers=headers,
stream=True,
timeout=15,
)
except requests.RequestException:
return HttpResponse(status=502)
response = StreamingHttpResponse(
upstream.iter_content(chunk_size=4096),
content_type=upstream.headers.get('Content-Type', 'audio/mpeg'),
status=upstream.status_code,
)
for h in ('icy-metaint', 'icy-name', 'icy-genre', 'icy-br', 'icy-description', 'icy-url'):
if h in upstream.headers:
response[h] = upstream.headers[h]
response['Cache-Control'] = 'no-cache'
return response
@csrf_exempt
@require_http_methods(['POST'])
def creamfresh_feedback(request):
"""Relays a thumbs up/down to the creamfresh DJ's own /dj/feedback --
same server-side-credentials reasoning as creamfresh_stream above."""
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
vote = body.get('vote')
if vote not in ('up', 'down'):
return JsonResponse({'error': "vote must be 'up' or 'down'"}, status=400)
try:
upstream = requests.post(
'https://radio.creamfresh.xyz/dj/feedback',
auth=CREAMFRESH_AUTH,
json={'vote': vote},
timeout=15,
)
except requests.RequestException:
return JsonResponse({'error': 'upstream unreachable'}, status=502)
return JsonResponse(upstream.json(), status=upstream.status_code, safe=False)
def stream_player(request): def stream_player(request):
url = request.GET.get('url', '').strip() url = request.GET.get('url', '').strip()
name = request.GET.get('name', '').strip() name = request.GET.get('name', '').strip()

View file

@ -1,7 +1,19 @@
django>=4.2 # Pinned to exact versions. These are what production runs and what the test
pylast>=5.2 # suite is green against — with `>=` the image you get depends on the day you
requests>=2.31 # build it, which is how a rebuild once swapped in a gunicorn that could not
python-dotenv>=1.0 # boot the gevent worker at all.
whitenoise>=6.6 #
feedparser>=6.0 # Nothing here updates on its own any more, so bump deliberately: change a
gevent>=24.0 # version, let CI run, then deploy.
Django==6.1
pylast==7.1.0
requests==2.34.2
python-dotenv==1.2.3
whitenoise==6.12.0
feedparser==6.0.14
gevent==26.8.0
# gunicorn 26.2.0 imports packaging.version in ggevent.py while declaring no
# dependencies of its own, so packaging has to be requested explicitly.
gunicorn==26.2.0
packaging==26.3
piper-tts==1.7.0

View file

@ -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
========================================================= */ ========================================================= */
@ -1481,6 +1636,12 @@ body.dnd-mode .timer-display {
padding: 4px 6px; font-size: 0.82rem; cursor: pointer; padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
} }
.tts-lang-select {
background: var(--surface, #1e1e2e); color: var(--fg, #fff);
border: 1px solid var(--border, #444); border-radius: 4px;
padding: 2px 4px; font-size: 0.8rem; cursor: pointer;
}
.reader-marker-btn-mobile { display: none; } .reader-marker-btn-mobile { display: none; }
@media (max-width: 600px) { @media (max-width: 600px) {
@ -1583,8 +1744,14 @@ body.dnd-mode .timer-display {
.book-progress { .book-progress {
font-size: 12px; font-size: 12px;
} }
.book-item-meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.book-item-actions { .book-item-actions {
display: flex; display: flex;
align-items: center;
gap: 6px; gap: 6px;
flex-shrink: 0; flex-shrink: 0;
} }
@ -1592,6 +1759,56 @@ body.dnd-mode .timer-display {
font-size: 12px; font-size: 12px;
font-weight: normal; font-weight: normal;
} }
.book-shelf-badge {
display: inline-block;
font-size: 11px;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 10px;
color: var(--muted, #888);
white-space: nowrap;
}
/* --- Per-book "⋮" menu (everything except Open) --- */
.book-item-menu {
position: relative;
}
.book-item-menu-list {
display: none;
flex-direction: column;
gap: 2px;
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 20;
min-width: 220px;
background: var(--surface, #111);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.book-item-menu-list.open {
display: flex;
}
.book-menu-item {
background: none;
border: none;
color: var(--fg);
text-align: left;
padding: 8px 10px;
border-radius: var(--radius);
cursor: pointer;
font: inherit;
font-size: 13px;
white-space: nowrap;
}
.book-menu-item:hover {
background: var(--bg-row, rgba(255, 255, 255, 0.08));
}
.book-menu-item--danger {
color: var(--accent, #e63946);
}
.book-list-filter { .book-list-filter {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1602,6 +1819,64 @@ body.dnd-mode .timer-display {
cursor: pointer; cursor: pointer;
} }
/* --- Recently-read section (always pinned atop the books view, plain list style) --- */
.book-recent-section {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.book-recent-title {
font-size: 13px;
font-weight: 600;
margin: 0;
color: var(--muted, #888);
}
/* --- Book folders (single level) --- */
.book-folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.book-folder-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: none;
color: var(--fg);
cursor: pointer;
text-align: center;
font: inherit;
}
.book-folder-tile-icon {
font-size: 22px;
}
.book-folder-tile-name {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.book-folder-tile-count {
font-size: 11px;
color: var(--muted, #888);
}
.book-folder-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
/* --- PDF pages --- */ /* --- PDF pages --- */
.pdf-page-wrapper { .pdf-page-wrapper {
margin: 0 auto 1rem; margin: 0 auto 1rem;
@ -1928,6 +2203,12 @@ mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border
mark.reader-search-match.active { background:rgba(230,57,70,.7); } mark.reader-search-match.active { background:rgba(230,57,70,.7); }
#rs-search-count { font-size:12px; min-width:50px; } #rs-search-count { font-size:12px; min-width:50px; }
/* Read-aloud (TTS) */
mark.tts-current { background:rgba(230,57,70,.55); color:inherit; border-radius:2px; }
.tts-bar { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:14px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:8px 16px; box-shadow:0 4px 16px rgba(0,0,0,.5); z-index:600; }
.tts-bar button { background:none; border:none; color:inherit; font-size:16px; cursor:pointer; padding:2px 4px; line-height:1; }
.tts-bar button:hover { opacity:0.7; }
/* Bookmarks sidebar */ /* Bookmarks sidebar */
.bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); } .bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); }

View file

@ -9,6 +9,10 @@
// State // State
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Hardcoded for now -- only creamfresh radio gets the vote buttons, since
// only its backend (the DJ) actually does anything with them.
const CREAMFRESH_RADIO_URL = 'https://diora.creamfresh.xyz/radio/creamfresh-stream/';
let currentStation = null; // { url, name, id } | null let currentStation = null; // { url, name, id } | null
let currentTrack = ''; let currentTrack = '';
let sseSource = null; let sseSource = null;
@ -176,6 +180,12 @@ function playStation(url, name, stationId) {
$('play-stop-btn').classList.add('playing'); $('play-stop-btn').classList.add('playing');
$('save-station-btn').style.display = ''; $('save-station-btn').style.display = '';
const isCreamfresh = url === CREAMFRESH_RADIO_URL;
$('creamfresh-vote-up-btn').style.display = isCreamfresh ? '' : 'none';
$('creamfresh-vote-down-btn').style.display = isCreamfresh ? '' : 'none';
$('creamfresh-vote-up-btn').classList.remove('active');
$('creamfresh-vote-down-btn').classList.remove('active');
startMetadataSSE(url); startMetadataSSE(url);
startPlaySession(name, url); startPlaySession(name, url);
maybeShowDonationHint(url, name); maybeShowDonationHint(url, name);
@ -220,6 +230,8 @@ function stopPlayback(clearStation = true) {
$('play-stop-btn').textContent = '▶ Play'; $('play-stop-btn').textContent = '▶ Play';
$('play-stop-btn').classList.remove('playing'); $('play-stop-btn').classList.remove('playing');
$('save-station-btn').style.display = 'none'; $('save-station-btn').style.display = 'none';
$('creamfresh-vote-up-btn').style.display = 'none';
$('creamfresh-vote-down-btn').style.display = 'none';
$('affiliate-section').style.display = 'none'; $('affiliate-section').style.display = 'none';
stopPlaySession(); stopPlaySession();
@ -604,6 +616,36 @@ async function saveCurrentStation() {
await saveStation(data); await saveStation(data);
} }
// ---------------------------------------------------------------------------
// creamfresh radio: DJ feedback (hardcoded to this one station, see
// CREAMFRESH_RADIO_URL above)
// ---------------------------------------------------------------------------
async function creamfreshVote(direction) {
const upBtn = $('creamfresh-vote-up-btn');
const downBtn = $('creamfresh-vote-down-btn');
upBtn.disabled = true;
downBtn.disabled = true;
try {
const res = await fetch('/radio/creamfresh-feedback/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({ vote: direction }),
});
if (!res.ok) throw new Error(`feedback returned ${res.status}`);
upBtn.classList.toggle('active', direction === 'up');
downBtn.classList.toggle('active', direction === 'down');
} catch (err) {
console.warn('creamfresh vote failed', err);
} finally {
upBtn.disabled = false;
downBtn.disabled = false;
}
}
async function saveStation(station) { async function saveStation(station) {
try { try {
const res = await fetch('/radio/save/', { const res = await fetch('/radio/save/', {
@ -3249,11 +3291,11 @@ async function loadBookList() {
try { try {
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
const meta = JSON.parse(new TextDecoder().decode(metaBuf)); const meta = JSON.parse(new TextDecoder().decode(metaBuf));
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'}; bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || ''};
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
} catch (e) { } catch (e) {
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'}; bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''};
decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
} }
} }
// If local cache is further ahead than the server, push it and use it for display. // If local cache is further ahead than the server, push it and use it for display.
@ -3292,7 +3334,7 @@ async function loadBookList() {
if (b.last_read) return 1; if (b.last_read) return 1;
return (b.uploaded_at || '').localeCompare(a.uploaded_at || ''); return (b.uploaded_at || '').localeCompare(a.uploaded_at || '');
}); });
for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub'}; for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || ''};
const uploadArea = $('book-upload-area'); const uploadArea = $('book-upload-area');
if (uploadArea) uploadArea.style.display = 'none'; if (uploadArea) uploadArea.style.display = 'none';
renderBookList(cachedBooks); renderBookList(cachedBooks);
@ -3322,6 +3364,15 @@ function markBookBroken(bookId) {
} }
} }
function _extractIsbnFromOpf(opfDoc) {
const identifiers = opfDoc.querySelectorAll('metadata > identifier, metadata > *|identifier');
for (const el of identifiers) {
const digits = (el.textContent || '').replace(/[^0-9Xx]/g, '');
if (digits.length === 10 || digits.length === 13) return digits;
}
return '';
}
function repairBook(bookId) { function repairBook(bookId) {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
@ -3338,7 +3389,7 @@ function repairBook(bookId) {
const isPdf = /\.pdf$/i.test(file.name); const isPdf = /\.pdf$/i.test(file.name);
const type = isPdf ? 'pdf' : 'epub'; const type = isPdf ? 'pdf' : 'epub';
let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = ''; let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = '';
try { try {
if (isPdf) { if (isPdf) {
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
@ -3356,11 +3407,20 @@ function repairBook(bookId) {
await zip.file(opfPath).async('text'), 'application/xml'); await zip.file(opfPath).async('text'), 'application/xml');
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
isbn = _extractIsbnFromOpf(opfDoc);
} }
} }
} catch (e) { /* keep filename as title */ } } catch (e) { /* keep filename as title */ }
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type})); // Preserve folder/shelfTag assigned before this repair — replacing the book's
// data shouldn't silently wipe organization the user already set up.
const prevMeta = bookMetaCache[bookId] || {};
const metaJson = new TextEncoder().encode(JSON.stringify({
title, author, filename: file.name, type,
isbn: isbn || prevMeta.isbn || '',
folder: prevMeta.folder || '',
shelfTag: prevMeta.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),
@ -3415,38 +3475,284 @@ async function toggleBookRead(bookId, currentlyRead) {
} }
} }
// Folder assigned to a book lives inside its encrypted meta blob (like title/author), so the
// server never sees plaintext folder names — no schema change needed, and it rides along for
// free in /api/sync/. One level only: folders don't nest.
let _currentBookFolder = null; // null = root view (folder tiles + unfiled books)
function _recentlyReadBooks(books) {
return books
.filter(b => b.last_read)
.sort((a, b) => b.last_read.localeCompare(a.last_read))
.slice(0, 7);
}
function _renderBookItemHtml(b) {
const pct = Math.round((b.scroll_fraction || 0) * 100);
const keyWarning = b.keyOk === false ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠ wrong key</span>' : '';
const broken = _brokenBooks.has(b.id);
return `<div class="book-item" data-book-id="${b.id}">
<div class="book-item-info">
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' <span class="muted book-read-badge">✓ gelesen</span>' : ''}</strong>
<span class="muted book-author">${escapeHtml(b.author)}</span>
<span class="book-item-meta-row">
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
${b.shelfTag ? `<span class="book-shelf-badge" title="Regal-Kategorie">${escapeHtml(b.shelfTag)}</span>` : ''}
</span>
</div>
<div class="book-item-actions">
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button>
<div class="book-item-menu">
<button class="btn btn-sm book-menu-toggle" title="Weitere Optionen" onclick="toggleBookMenu(this)"></button>
<div class="book-item-menu-list">
${broken ? `<button class="book-menu-item book-menu-item--danger book-broken-btn" onclick="repairBook(${b.id})">🔧 Reparieren (Datei erneut hochladen)</button>` : ''}
${b.has_highlights ? `<button class="book-menu-item" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳ Markierungen &amp; Notizen herunterladen</button>` : ''}
<button class="book-menu-item" onclick="assignBookFolder(${b.id})">📁 Ordner zuweisen</button>
<button class="book-menu-item" onclick="lookupBookMetadata(${b.id})">🏷 Metadaten abrufen (Regal)</button>
<button class="book-menu-item" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺ Als ungelesen markieren' : '✓ Als gelesen markieren'}</button>
<button class="book-menu-item book-menu-item--danger" onclick="deleteBook(${b.id})">🗑 Löschen</button>
</div>
</div>
</div>
</div>`;
}
function toggleBookMenu(toggleBtn) {
// Looked up relative to the clicked button (not by id) because the same book — and
// thus the same rendered menu — can appear twice at once: once in the "Zuletzt
// gelesen" section and again further down in the folder/unfiled list.
const menu = toggleBtn.closest('.book-item-menu')?.querySelector('.book-item-menu-list');
if (!menu) return;
const willOpen = !menu.classList.contains('open');
document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open'));
if (willOpen) menu.classList.add('open');
}
// Close any open book menu on an outside click, or right after an item inside it is
// clicked (the toggle button manages its own open/close state above, so it's excluded).
document.addEventListener('click', (e) => {
if (e.target.closest('.book-menu-toggle')) return;
document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open'));
});
function _openBookFolder(name) {
_currentBookFolder = name;
renderBookList(_lastBookListData);
}
function renderBookList(books) { function renderBookList(books) {
const listEl = $('book-list'); const listEl = $('book-list');
if (!listEl) return; if (!listEl) return;
_lastBookListData = books; _lastBookListData = books;
const visible = _bookShowRead ? books : books.filter(b => !b.is_read);
if (!visible.length) { if (!books.length) {
listEl.innerHTML = books.length listEl.innerHTML = '';
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
: '';
return; return;
} }
let html = ''; let html = '';
for (const b of visible) {
const pct = Math.round((b.scroll_fraction || 0) * 100); // Always-visible "recently read" section, independent of folder navigation and the
const keyWarning = b.keyOk === false ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠ wrong key</span>' : ''; // read/unread filter below — it's a shortcut back into whatever was open last. Same
const broken = _brokenBooks.has(b.id); // vertical list-item look as the rest of the list (not a card shelf).
html += `<div class="book-item" data-book-id="${b.id}"> const recent = _recentlyReadBooks(books);
<div class="book-item-info"> if (recent.length) {
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' <span class="muted book-read-badge">✓ gelesen</span>' : ''}</strong> html += '<div class="book-recent-section">'
<span class="muted book-author">${escapeHtml(b.author)}</span> + '<h3 class="book-recent-title">Zuletzt gelesen</h3>'
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''} + recent.map(_renderBookItemHtml).join('')
</div> + '</div>';
<div class="book-item-actions">
${broken ? `<button class="btn btn-sm btn-danger book-broken-btn" title="Buch konnte nicht geöffnet werden — Datei erneut hochladen" onclick="repairBook(${b.id})">!</button>` : ''}
${b.has_highlights ? `<button class="btn btn-sm" title="Markierungen &amp; Notizen herunterladen" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳</button>` : ''}
<button class="btn btn-sm" title="${b.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren'}" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺' : '✓'}</button>
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button>
<button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button>
</div>
</div>`;
} }
const visible = _bookShowRead ? books : books.filter(b => !b.is_read);
if (!visible.length) {
html += books.length
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
: '';
listEl.innerHTML = html;
return;
}
const grouped = new Map();
for (const b of visible) {
const key = b.folder || '';
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(b);
}
// Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root.
if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) {
_currentBookFolder = null;
}
if (_currentBookFolder === null) {
const folderNames = [...grouped.keys()].filter(k => k !== '').sort((a, b) => a.localeCompare(b, 'de'));
if (folderNames.length) {
html += '<div class="book-folder-grid">'
+ folderNames.map(name => `<button class="book-folder-tile" data-folder="${escapeHtml(name)}">
<span class="book-folder-tile-icon">📁</span>
<span class="book-folder-tile-name">${escapeHtml(name)}</span>
<span class="book-folder-tile-count">${grouped.get(name).length}</span>
</button>`).join('')
+ '</div>';
}
html += (grouped.get('') || []).map(_renderBookItemHtml).join('');
} else {
html += `<div class="book-folder-header">
<button class="btn btn-sm" onclick="_openBookFolder(null)"> Alle Ordner</button>
<strong>${escapeHtml(_currentBookFolder)}</strong>
</div>`;
html += grouped.get(_currentBookFolder).map(_renderBookItemHtml).join('');
}
listEl.innerHTML = html; listEl.innerHTML = html;
listEl.querySelectorAll('.book-folder-tile').forEach(tile => {
tile.addEventListener('click', () => _openBookFolder(tile.dataset.folder));
});
}
// Shared by assignBookFolder/lookupBookMetadata: re-encrypts the meta blob with the given
// field overrides applied on top of whatever is already cached, and pushes it to the server.
async function _updateBookMeta(bookId, overrides) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return false;
const cached = bookMetaCache[bookId] || {};
const merged = {
title: cached.title || book.title,
author: cached.author || book.author,
filename: cached.filename || '',
type: cached.type || book.type || 'epub',
isbn: cached.isbn || book.isbn || '',
folder: cached.folder || book.folder || '',
shelfTag: cached.shelfTag || book.shelfTag || '',
...overrides,
};
const key = await getOrCreateEncKey();
const metaJson = new TextEncoder().encode(JSON.stringify(merged));
const metaEnc = await encryptBytes(key, metaJson);
const res = await fetch(`/books/${bookId}/meta/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}),
});
const data = await res.json();
if (!data.ok) throw new Error('Server error');
Object.assign(book, overrides);
bookMetaCache[bookId] = merged;
_saveBookMeta(_lastBookListData);
return true;
}
async function assignBookFolder(bookId) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return;
const folders = [...new Set(_lastBookListData.map(b => b.folder).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de'));
const hint = folders.length ? ` Vorhandene Ordner: ${folders.join(', ')}.` : '';
const result = await customPrompt(`Ordner für „${book.title}“ (leer = kein Ordner).${hint}`, book.folder || '');
if (result === null) return;
const folder = result.trim();
if (folder === (book.folder || '')) return;
try {
await _updateBookMeta(bookId, {folder});
if (folder) _currentBookFolder = folder;
renderBookList(_lastBookListData);
} catch (e) {
await customAlert('Konnte Ordner nicht ändern: ' + e.message);
}
}
// Fallback for books uploaded before ISBN extraction existed (or repaired/re-encrypted
// without it): pulls the already-accessible encrypted book bytes, decrypts and unzips just
// far enough to read the OPF identifiers — same source openBook() itself parses — without
// doing a full reader render. EPUB only; PDFs never carried a reliable ISBN via pdf.js.
async function _extractIsbnFromBookFile(bookId) {
try {
const key = await getOrCreateEncKey();
let data_ct, data_iv;
const cached = await _getCachedBook(bookId);
if (cached) {
({data_ct, data_iv} = cached);
} else {
const res = await fetch(`/books/${bookId}/data/`);
({data_ct, data_iv} = await res.json());
}
const plain = await decryptBytes(key, data_iv, data_ct);
const zip = await JSZip.loadAsync(plain);
const containerXml = await zip.file('META-INF/container.xml').async('text');
const opfPath = new DOMParser()
.parseFromString(containerXml, 'application/xml')
.querySelector('rootfile')?.getAttribute('full-path');
if (!opfPath) return '';
const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml');
return _extractIsbnFromOpf(opfDoc);
} catch (e) {
return '';
}
}
async function _fetchShelfLookup(isbn, title, author) {
const params = new URLSearchParams();
if (isbn) params.set('isbn', isbn);
if (title) params.set('title', title);
if (author) params.set('author', author);
const res = await fetch(`/books/metadata-lookup/?${params.toString()}`);
return res.json();
}
// Manually triggered (never automatic) — looks up which library shelf/DDC category this
// book falls under via the server-side DNB→Open Library proxy, purely as an organizational
// hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is
// allowed to briefly touch the server with a plaintext ISBN/title/author.
//
// Fallback chain when there's no ISBN (older upload, or a book that never had one — e.g.
// every PDF, since pdf.js doesn't expose one): (1) extract ISBN from the file itself, for
// EPUBs, (2) server-side title/author text search (less precise — could match the wrong
// edition/translation), (3) if that also comes up empty, ask the user to type in an ISBN
// by hand (from the book's cover/an online store) and retry once.
async function lookupBookMetadata(bookId) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return;
const cached = bookMetaCache[bookId] || {};
let isbn = cached.isbn || book.isbn || '';
const isPdf = (cached.type || book.type) === 'pdf';
const title = cached.title || book.title || '';
const author = cached.author || book.author || '';
if (!isbn && !isPdf) {
// Older upload without a stored ISBN — extract it from the book file itself (may take
// a moment for large books, since it downloads/decrypts the full file if not cached).
isbn = await _extractIsbnFromBookFile(bookId);
}
try {
let data = await _fetchShelfLookup(isbn, title, author);
if (!data.label) {
const manual = await customPrompt(
'Keine Regal-Kategorie per ISBN/Titel/Autor gefunden. ISBN manuell eingeben (z. B. vom Buchrücken oder Online-Shop), oder leer lassen zum Abbrechen:', ''
);
const manualIsbn = (manual || '').replace(/[^0-9Xx]/g, '');
if (manualIsbn) {
isbn = manualIsbn;
data = await _fetchShelfLookup(isbn, title, author);
}
}
// Persist whatever we ended up with either way, so a repeat lookup never needs to
// re-extract/re-search from scratch.
const overrides = {};
if (isbn) overrides.isbn = isbn;
if (data.label) overrides.shelfTag = data.label;
if (Object.keys(overrides).length) {
await _updateBookMeta(bookId, overrides);
renderBookList(_lastBookListData);
}
if (!data.label) {
await customAlert('Keine Regal-Kategorie gefunden (weder DNB noch Open Library, per ISBN oder Titel/Autor).');
}
} catch (e) {
await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message);
}
} }
function bookFileSelected(input) { function bookFileSelected(input) {
@ -3512,24 +3818,33 @@ 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 = '';
const type = isPdf ? 'pdf' : 'epub'; const type = isPdf ? 'pdf' : 'epub';
if (isPdf) { if (isPdf) {
@ -3550,12 +3865,13 @@ async function uploadEbook(file) {
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
isbn = _extractIsbnFromOpf(opfDoc);
} }
} catch (e) { /* use filename as title */ } } catch (e) { /* use filename as title */ }
} }
const key = await getOrCreateEncKey(); const key = await getOrCreateEncKey();
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type})); 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),
@ -3577,11 +3893,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;
} }
} }
@ -3708,6 +4178,239 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
} }
// ---------------------------------------------------------------------------
// Read-aloud (TTS) — server-side Piper synthesis, one sentence per request.
// EPUB only (no block model for PDFs). Highlights the sentence currently
// playing and scrolls it into view; playback advances sentence-by-sentence,
// then block-by-block, until stopped or the book ends.
// ---------------------------------------------------------------------------
const TTS_MAX_CHARS = 480; // stay under the server's 500-char cap with margin
let ttsActive = false;
let ttsPaused = false;
let ttsRunToken = 0;
let ttsAudio = null;
let ttsCurrentMark = null;
let ttsBarEl = null;
let ttsLang = localStorage.getItem('diora_tts_lang') || 'de';
function setTtsLang(lang) {
ttsLang = lang;
localStorage.setItem('diora_tts_lang', lang);
}
function _ttsSplitSentences(text) {
const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean);
const pieces = raw.length ? raw : [text];
const out = [];
for (const piece of pieces) {
let rest = piece;
while (rest.length > TTS_MAX_CHARS) {
let cut = rest.lastIndexOf(' ', TTS_MAX_CHARS);
if (cut <= 0) cut = TTS_MAX_CHARS;
out.push(rest.slice(0, cut).trim());
rest = rest.slice(cut).trim();
}
if (rest) out.push(rest);
}
return out;
}
async function _ttsFetchAudio(sentence) {
const resp = await fetch('/tts/synthesize/', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: sentence, lang: ttsLang}),
});
if (!resp.ok) throw new Error('tts request failed');
return await resp.blob();
}
function _ttsClearHighlight() {
if (ttsCurrentMark && ttsCurrentMark.parentNode) {
const parent = ttsCurrentMark.parentNode;
while (ttsCurrentMark.firstChild) parent.insertBefore(ttsCurrentMark.firstChild, ttsCurrentMark);
parent.removeChild(ttsCurrentMark);
parent.normalize();
}
ttsCurrentMark = null;
}
// Finds `sentence` as a substring of block.textContent starting at fromOffset
// (so repeated sentence text earlier in the block isn't matched again), turns
// it into a DOM Range via the same char-offset addressing highlights use, and
// wraps it in a <mark>. Returns the offset to resume searching from.
function _ttsHighlightSentence(block, sentence, fromOffset) {
_ttsClearHighlight();
const full = block.textContent;
const idx = full.indexOf(sentence, fromOffset);
if (idx === -1) return fromOffset;
const start = _nodeAtCharOffset(block, idx);
const end = _nodeAtCharOffset(block, idx + sentence.length);
if (!start || !end) return idx + sentence.length;
try {
const range = document.createRange();
range.setStart(start.node, start.offset);
range.setEnd(end.node, end.offset);
const mark = document.createElement('mark');
mark.className = 'tts-current';
range.surroundContents(mark);
ttsCurrentMark = mark;
_ttsScrollIntoView(mark);
} catch (e) {}
return idx + sentence.length;
}
function _ttsScrollIntoView(el) {
const contentEl = $('reader-content');
if (!contentEl) return;
const top = el.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
if (top < 40 || top > contentEl.clientHeight - 80) {
_suppressScrollJumpDetect();
contentEl.scrollBy({top: top - contentEl.clientHeight * 0.3, behavior: 'smooth'});
}
}
function _ttsPlayBlob(blob) {
return new Promise((resolve) => {
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
ttsAudio = audio;
const done = () => { URL.revokeObjectURL(url); resolve(); };
audio.addEventListener('ended', done);
audio.addEventListener('error', done);
if (!ttsPaused) audio.play().catch(done);
});
}
// Walks blocks/sentences from startBlockIndex onward, fetching one sentence
// ahead while the current one plays so there's no gap between them.
async function _ttsPlayLoop(blocks, startBlockIndex, runToken) {
function* sentenceStream() {
for (let bi = startBlockIndex; bi < blocks.length; bi++) {
const block = blocks[bi];
const text = (block.textContent || '').trim();
if (!text) continue;
for (const sentence of _ttsSplitSentences(text)) yield {block, sentence};
}
}
const iter = sentenceStream();
let cur = iter.next();
if (cur.done) { stopReadAloud(); return; }
let curFetch = _ttsFetchAudio(cur.value.sentence);
let lastBlock = null;
let blockSearchOffset = 0;
while (!cur.done) {
if (runToken !== ttsRunToken) return;
const {block, sentence} = cur.value;
const next = iter.next();
const nextFetch = next.done ? null : _ttsFetchAudio(next.value.sentence).catch(() => null);
let audioBlob;
try {
audioBlob = await curFetch;
} catch (e) {
stopReadAloud();
return;
}
if (runToken !== ttsRunToken) return;
if (block !== lastBlock) { lastBlock = block; blockSearchOffset = 0; }
blockSearchOffset = _ttsHighlightSentence(block, sentence, blockSearchOffset);
await _ttsPlayBlob(audioBlob);
if (runToken !== ttsRunToken) return;
cur = next;
curFetch = nextFetch;
}
if (runToken === ttsRunToken) stopReadAloud();
}
function _ttsTogglePause() {
ttsPaused = !ttsPaused;
if (ttsAudio) {
if (ttsPaused) ttsAudio.pause();
else ttsAudio.play().catch(() => {});
}
_ttsUpdateBar();
}
function _ttsUpdateBar() {
if (!ttsBarEl) return;
const btn = ttsBarEl.querySelector('.tts-pause-btn');
if (btn) btn.textContent = ttsPaused ? '▶' : '⏸';
}
function _ttsShowBar() {
_ttsRemoveBar();
const bar = document.createElement('div');
bar.className = 'tts-bar';
bar.innerHTML = `
<button type="button" class="tts-pause-btn" title="Pause/Weiter"></button>
<button type="button" class="tts-stop-btn" title="Vorlesen beenden"></button>
`;
document.body.appendChild(bar);
ttsBarEl = bar;
bar.querySelector('.tts-pause-btn').addEventListener('click', _ttsTogglePause);
bar.querySelector('.tts-stop-btn').addEventListener('click', stopReadAloud);
}
function _ttsRemoveBar() {
if (ttsBarEl) { ttsBarEl.remove(); ttsBarEl = null; }
}
function toggleReadAloud() {
if (ttsActive) stopReadAloud();
else startReadAloud();
}
function startReadAloud() {
if (ttsActive) return;
if (currentPdfDoc) {
const toast = document.createElement('div');
toast.className = 'reader-toast';
toast.textContent = 'Vorlesen ist aktuell nur für EPUB-Bücher verfügbar.';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2200);
return;
}
const contentEl = $('reader-content');
if (!contentEl) return;
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
if (!blocks.length) return;
const [anchorBlock] = _anchorParts(getPositionAnchor(contentEl));
const startIndex = (anchorBlock >= 0 && anchorBlock < blocks.length) ? anchorBlock : 0;
ttsActive = true;
ttsPaused = false;
ttsRunToken++;
const runToken = ttsRunToken;
const btn = $('reader-tts-btn');
if (btn) { btn.classList.add('active'); btn.title = 'Vorlesen beenden'; }
_ttsShowBar();
_ttsPlayLoop(blocks, startIndex, runToken);
}
function stopReadAloud() {
ttsRunToken++;
ttsActive = false;
ttsPaused = false;
if (ttsAudio) {
try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {}
ttsAudio = null;
}
_ttsClearHighlight();
_ttsRemoveBar();
const btn = $('reader-tts-btn');
if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; }
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Immersive reader mode — tap centre of screen to toggle bars // Immersive reader mode — tap centre of screen to toggle bars
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -3715,7 +4418,7 @@ let _immBarsVisible = true;
function _immHandleTap(e) { function _immHandleTap(e) {
// Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover) // Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover)
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet')) return; if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet, .tts-bar')) return;
// In marker mode, taps have a dedicated meaning (highlight/create a note) — // In marker mode, taps have a dedicated meaning (highlight/create a note) —
// don't also toggle the immersive bars underneath. // don't also toggle the immersive bars underneath.
if (markerModeActive) return; if (markerModeActive) return;
@ -4114,6 +4817,7 @@ async function saveReaderProgress(force = false) {
function closeReader() { function closeReader() {
exitReaderImmersiveMode(); exitReaderImmersiveMode();
stopReadAloud();
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none // Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
saveReaderProgress(); saveReaderProgress();
if (bookmarksDirty) saveBookmarks(); if (bookmarksDirty) saveBookmarks();
@ -6113,6 +6817,10 @@ function openRadioSidebar() {
setVolume(vol); setVolume(vol);
} }
// Restore persisted read-aloud language
const ttsLangSelect = $('reader-tts-lang');
if (ttsLangSelect) ttsLangSelect.value = ttsLang;
// Load recommendations on page load // Load recommendations on page load
loadRecommendations(); loadRecommendations();

View file

@ -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-v35'; const CACHE = 'diora-v43';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
@ -53,6 +53,7 @@ self.addEventListener('fetch', function (event) {
if (url.pathname.startsWith('/radio/sse/') || if (url.pathname.startsWith('/radio/sse/') ||
url.pathname.startsWith('/radio/record/') || url.pathname.startsWith('/radio/record/') ||
url.pathname.startsWith('/radio/affiliate/') || url.pathname.startsWith('/radio/affiliate/') ||
url.pathname.startsWith('/tts/') ||
url.pathname.startsWith('/admin/') || url.pathname.startsWith('/admin/') ||
url.pathname.startsWith('/podcasts/progress/') || url.pathname.startsWith('/podcasts/progress/') ||
url.pathname.startsWith('/podcasts/queue/') || url.pathname.startsWith('/podcasts/queue/') ||

View file

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

View file

@ -13,10 +13,12 @@
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">&#9654; Play</button> <button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">&#9654; Play</button>
<label class="volume-label"> <label class="volume-label">
<span>vol</span> <span>vol</span>
<input type="range" id="volume" min="0" max="255" value="204" class="volume-slider"> <input type="range" id="volume" min="0" max="255" value="102" class="volume-slider">
<input type="number" id="volume-num" min="0" max="255" value="204" class="volume-num"> <input type="number" id="volume-num" min="0" max="255" value="102" class="volume-num">
</label> </label>
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</button> <button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</button>
<button class="btn-icon" id="creamfresh-vote-up-btn" style="display:none;" onclick="creamfreshVote('up')" title="Gefällt mir">&#128077;</button>
<button class="btn-icon" id="creamfresh-vote-down-btn" style="display:none;" onclick="creamfreshVote('down')" title="Gefällt mir nicht">&#128078;</button>
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)"></button> <button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)"></button>
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio"></button> <button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio"></button>
</div> </div>
@ -319,6 +321,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)">
@ -344,6 +356,11 @@
</span> </span>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search"></button> <button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search"></button>
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button> <button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button>
<select id="reader-tts-lang" class="tts-lang-select" title="Vorlese-Sprache" onchange="setTtsLang(this.value)">
<option value="de">DE</option>
<option value="en">EN</option>
</select>
<button class="btn-icon" id="reader-tts-btn" onclick="toggleReadAloud()" title="Vorlesen"></button>
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button> <button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button>
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button> <button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button>
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents"></button> <button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents"></button>

0
tts/__init__.py Normal file
View file

6
tts/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TtsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'tts'

41
tts/piper_engine.py Normal file
View file

@ -0,0 +1,41 @@
import io
import threading
import wave
from django.conf import settings
DEFAULT_LANGUAGE = 'de'
SUPPORTED_LANGUAGES = tuple(settings.TTS_VOICES.keys())
# Lazy per-process singletons, one per language: each gunicorn worker loads a
# voice only once it's actually requested, rather than all workers loading
# every model at startup (the host runs several other containers with limited
# spare RAM). One lock guards both the lazy-load and the inference call below
# — a single self-hosted user never needs concurrent synthesis across
# languages, so there's no reason for a lock per voice.
_voices = {}
_lock = threading.Lock()
def _get_voice(lang):
if lang not in _voices:
with _lock:
if lang not in _voices:
from piper import PiperVoice
_voices[lang] = PiperVoice.load(str(settings.TTS_VOICES[lang]))
return _voices[lang]
def synthesize_wav(text, lang=DEFAULT_LANGUAGE):
"""Synthesize `text` (in `lang`) to WAV bytes.
Never persists or logs `text` callers must not log it either. The lock
also serializes inference, since one onnxruntime session isn't meant to
run concurrent calls within a process.
"""
voice = _get_voice(lang)
buf = io.BytesIO()
with _lock:
with wave.open(buf, 'wb') as wav_file:
voice.synthesize_wav(text, wav_file)
return buf.getvalue()

55
tts/tests.py Normal file
View file

@ -0,0 +1,55 @@
from unittest.mock import patch
from django.contrib.auth.models import User
from django.test import TestCase
from . import piper_engine
class TtsSynthesizeTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
def test_requires_auth(self):
resp = self.client.post('/tts/synthesize/', {'text': 'Hallo'}, content_type='application/json')
self.assertEqual(resp.status_code, 401)
def test_rejects_empty_text(self):
self.client.force_login(self.user)
resp = self.client.post('/tts/synthesize/', {'text': ' '}, content_type='application/json')
self.assertEqual(resp.status_code, 400)
def test_rejects_text_over_limit(self):
self.client.force_login(self.user)
resp = self.client.post(
'/tts/synthesize/', {'text': 'a' * 501}, content_type='application/json')
self.assertEqual(resp.status_code, 400)
def test_rejects_invalid_json(self):
self.client.force_login(self.user)
resp = self.client.post('/tts/synthesize/', 'not json', content_type='application/json')
self.assertEqual(resp.status_code, 400)
@patch.object(piper_engine, 'synthesize_wav', return_value=b'RIFF....WAVEfmt fake')
def test_synthesizes_audio_default_lang(self, mock_synth):
self.client.force_login(self.user)
resp = self.client.post(
'/tts/synthesize/', {'text': 'Hallo Welt.'}, content_type='application/json')
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp['Content-Type'], 'audio/wav')
self.assertEqual(b''.join(resp.streaming_content), b'RIFF....WAVEfmt fake')
mock_synth.assert_called_once_with('Hallo Welt.', 'de')
@patch.object(piper_engine, 'synthesize_wav', return_value=b'RIFF....WAVEfmt fake')
def test_synthesizes_audio_explicit_lang(self, mock_synth):
self.client.force_login(self.user)
resp = self.client.post(
'/tts/synthesize/', {'text': 'Hello world.', 'lang': 'en'}, content_type='application/json')
self.assertEqual(resp.status_code, 200)
mock_synth.assert_called_once_with('Hello world.', 'en')
def test_rejects_unsupported_lang(self):
self.client.force_login(self.user)
resp = self.client.post(
'/tts/synthesize/', {'text': 'Hallo', 'lang': 'fr'}, content_type='application/json')
self.assertEqual(resp.status_code, 400)

7
tts/urls.py Normal file
View file

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path('synthesize/', views.synthesize, name='tts_synthesize'),
]

57
tts/views.py Normal file
View file

@ -0,0 +1,57 @@
import json
from django.http import JsonResponse, StreamingHttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from gevent.threadpool import ThreadPool
from . import piper_engine
# One sentence per request, hard-capped — this is the load-bearing part of the
# "server never holds more than a small, transient snippet of book text"
# agreement (see CLAUDE.md), not just a client-side convention.
MAX_TEXT_LENGTH = 500
# Offloads the CPU-bound Piper inference off the gevent hub's event loop, so a
# synthesis call doesn't stall other concurrent greenlets (radio SSE, other
# requests) in the same worker the way a plain in-greenlet call would.
_synth_pool = ThreadPool(1)
def _require_auth(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return None
@csrf_exempt
@require_http_methods(['POST'])
def synthesize(request):
err = _require_auth(request)
if err:
return err
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
text = body.get('text', '')
if not isinstance(text, str) or not text.strip():
return JsonResponse({'error': 'text required'}, status=400)
if len(text) > MAX_TEXT_LENGTH:
return JsonResponse({'error': f'text exceeds {MAX_TEXT_LENGTH} characters'}, status=400)
lang = body.get('lang', piper_engine.DEFAULT_LANGUAGE)
if lang not in piper_engine.SUPPORTED_LANGUAGES:
return JsonResponse({'error': 'unsupported lang'}, status=400)
try:
audio = _synth_pool.apply(piper_engine.synthesize_wav, (text, lang))
except Exception:
return JsonResponse({'error': 'synthesis failed'}, status=500)
response = StreamingHttpResponse(iter([audio]), content_type='audio/wav')
response['Cache-Control'] = 'no-store'
response['X-Accel-Buffering'] = 'no'
return response

View file

@ -1,126 +0,0 @@
# diora-tui
EPUB-Reader im Terminal — die erste Stufe einer TUI-Version von diora.
Zeigt ein Buch als **eine durchgehende Ansicht** über alle Kapitel hinweg (wie diora's
Web-Reader), nicht Kapitel für Kapitel. Lesefortschritt wird als dieselbe
`"blockIndex:innerFraction"`-Positionsangabe geführt wie der Web-Reader (`books/models.py`,
`EBookProgress`) — `blockIndex` zählt dabei exakt wie `static/js/app.js`s
`EPUB_BLOCK_SELECTOR` (`p, h1-h6, li, blockquote, dt, dd, figcaption` + textbasierte
`div`s ohne Element-Kinder), fortlaufend über das ganze Buch. Dadurch ist die Position
zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in
beide Richtungen synchronisieren (siehe unten).
Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben
("furthest wins"), lokal in `progress.json` (`~/.local/share/diora-tui/`, via
`platformdirs`), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene
Position zurücksetzt.
## Setup
```bash
cd tui
pip install -e .
```
## Nutzung
```bash
diora-tui --library ~/Books # Standard: ~/Books
```
Tastenkürzel:
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen
- `n` — nächstes Kapitel, `p` — vorheriges Kapitel
- `f` — Fußnote in der Nähe der aktuellen Position anzeigen (Peek-Overlay, `Escape`/`f`/`q`
zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s
`_looksLikeFootnoteLink` (Link in/um `<sup>`, Klassenname mit note/footnote/fn, oder
`epub:type="noteref"`)
- `Enter` — markiertes Buch aus der Bibliothek öffnen
- `r` (in der Bibliothek) — gelesene Bücher ein-/ausblenden (siehe unten)
- `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek)
Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als
volle Terminalbreite bei breiten Fenstern).
Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit,
sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht.
Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand
des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher
standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json`
gespiegelt) — `r` zeigt sie wieder an, für diese Sitzung.
## Bücher + Fortschritt vom Server holen (`diora-tui sync`)
Sobald einmal Zugangsdaten gespeichert sind (`~/.config/diora-tui/config.json`, siehe
unten), synct `diora-tui` **automatisch** — einmal leise im Hintergrund beim Start (neue
Bücher + Fortschritt werden nachgeladen, die Bibliotheksliste aktualisiert sich von
selbst) und einmal beim Beenden über `q` (kurzer Moment Verzögerung, bevor die App
tatsächlich schließt). Der explizite Befehl ist für's Ersteinrichten und für
Nicht-interaktive Nutzung (Cron o.ä.):
```bash
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage
diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern
```
Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books/<id>/data/` herunter,
entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als
normale `.epub`-Dateien in `--library` ab (Dateiname `<id> - <Titel>.epub`) — von da an
funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden
beim nächsten Lauf übersprungen (kein erneuter Download). Der Fortschritt aus dem Snapshot
wird dabei ebenfalls übernommen (nur vorwärts, wie lokal auch).
Während ein so heruntergeladenes Buch geöffnet ist (erkennbar am `<id> - `-Dateinamens-
Präfix), schickt der Reader Fortschritts-Updates zusätzlich zurück an den Server
(`POST /books/<id>/progress/`, `force: false` — überschreibt also nie eine weiter
gelesene Position, egal ob die vom Web-Reader oder einem anderen Gerät stammt). Rein
lokale Bücher (ohne dieses Präfix) bleiben unangetastet, kein Netzwerkzugriff.
Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter
`~/.config/diora-tui/config.json`, `chmod 600`):
- **Server-URL** — z.B. `https://diora.creamfresh.xyz`.
- **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token".
- **Verschlüsselungs-Key** — der AES-256-Schlüssel, mit dem deine Bücher clientseitig
verschlüsselt wurden. Der `sync`-Prompt bietet zwei Wege:
1. **Aus Benutzername + Passwort ableiten** (Standard) — reproduziert exakt, was diora's
"Unlock with password"-Formular im Browser tut (PBKDF2-HMAC-SHA256, 200.000
Iterationen, Salt `"diora:" + username`; siehe `static/js/app.js:deriveAndStoreKey`).
Funktioniert nur, wenn der Account diesen Weg im Browser mindestens einmal benutzt
hat — sonst wurde der Key ursprünglich zufällig im Browser erzeugt, und diese
Ableitung trifft ihn nicht. `sync` meldet einen falschen Key als
Entschlüsselungsfehler (harmlos, kein Datenverlust), nie als falsches Ergebnis.
2. **Base64-Key direkt einfügen** — für den Fall, dass Weg 1 nicht passt. Der
Export-Button dafür ist im Browser-UI aktuell nicht verdrahtet (`exportEncKey()` in
`app.js` existiert, hat aber keinen sichtbaren Button); bis das nachgezogen ist, in
der Browser-Devtools-Konsole auf der diora-Seite (nicht `/accounts/settings/` — die
lädt `app.js` nicht) ausführen: `await exportEncKey()` — kopiert den Key ins
Clipboard, von dort ins `sync`-Prompt einfügen. Schlägt das mit einem
`NotAllowedError`/`InvalidAccessError` fehl, stattdessen direkt aus `localStorage`
lesen: `localStorage.getItem('diora_enc_key_' + window.USER_ID)`.
Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der
Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei.
## Performance bei großen Büchern
Sehr große Bücher (mehrstellige Tausend Absätze — z.B. Sammelbände) können beim
*ersten* Öffnen mehrere Sekunden bis niedrige zweistellige Sekunden brauchen (Parsing +
Zeilenumbruch-Berechnung für die durchgehende Ansicht). Ein zweites Öffnen desselben
Buchs bei gleicher Terminalbreite ist dank Cache (`~/.cache/diora-tui/layout_cache/`)
deutlich schneller. Das Rendering selbst skaliert nicht mit der Buchgröße — nur die
tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch
auf einmal.
## Grenzen der aktuellen Version
- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen,
wird gemeldet), da der Reader sie ohnehin nicht darstellen kann.
- Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt.
- `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung
(zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung
zählt primär `blockIndex`, der exakt mit dem Web-Reader übereinstimmt; `innerFraction`
ist nur ein Tiebreaker innerhalb desselben Blocks.

View file

@ -1 +0,0 @@
"""diora-tui: lokaler EPUB-Reader, Vorstufe eines diora-TUI-Clients."""

View file

@ -1,4 +0,0 @@
from diora_tui.app import main
if __name__ == "__main__":
main()

View file

@ -1,48 +0,0 @@
"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for
local clients"). Status: that API is provisional — expect endpoint/field
changes as the server-side implementation settles.
"""
from __future__ import annotations
import requests
class ApiError(Exception):
pass
def _headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def fetch_sync_snapshot(server_url: str, token: str) -> dict:
resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30)
if resp.status_code == 401:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
resp.raise_for_status()
return resp.json()
def fetch_book_data(server_url: str, token: str, book_id: int) -> dict:
resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=120)
if resp.status_code == 401:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
if resp.status_code == 404:
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
resp.raise_for_status()
return resp.json()
def post_progress(
server_url: str, token: str, book_id: int, *, scroll_fraction: float, position_anchor: str, force: bool = False
) -> None:
body = {"scroll_fraction": scroll_fraction, "position_anchor": position_anchor, "force": force}
resp = requests.post(
f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30
)
if resp.status_code == 401:
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
if resp.status_code == 404:
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
resp.raise_for_status()

View file

@ -1,231 +0,0 @@
"""diora-tui: terminal EPUB reader for diora, syncing books + reading progress
against a diora server (see project README)."""
from __future__ import annotations
import argparse
import asyncio
import getpass
from pathlib import Path
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import Footer, Header, Label, ListItem, ListView
from . import config as config_mod
from . import crypto, epub, library_meta, progress, remote
from .reader_screen import ReaderScreen
from .statusbar import StatusBar
DEFAULT_LIBRARY = Path.home() / "Books"
class LibraryScreen(Screen):
BINDINGS = [
Binding("enter", "open_selected", "Öffnen"),
Binding("r", "toggle_read_filter", "Gelesene ein-/ausblenden"),
Binding("q", "quit", "Beenden"),
]
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
self.paths: list[Path] = []
self.visible_paths: list[Path] = []
self.show_read = False
def compose(self) -> ComposeResult:
yield Header()
yield ListView(id="library-list")
yield StatusBar()
yield Footer()
def on_mount(self) -> None:
self.sub_title = str(self.library_dir)
self._refresh_list()
cfg = config_mod.load()
if cfg is not None:
self._auto_sync(cfg)
@work(thread=True)
def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None:
try:
result = remote.sync_library(self.library_dir, cfg)
except remote.SyncError:
return # best-effort — e.g. offline; the local library still works
self.app.call_from_thread(self._on_auto_sync_done, result)
def _on_auto_sync_done(self, result: remote.SyncResult) -> None:
if result.downloaded or result.progress_pulled:
self._refresh_list()
if result.downloaded:
self.notify(f"{len(result.downloaded)} neue(s) Buch/Bücher synchronisiert.", timeout=3)
def _refresh_list(self) -> None:
self.paths = epub.scan_library(self.library_dir)
entries = []
for path in self.paths:
book_id = epub._book_id(path)
read = library_meta.is_read(book_id)
if read and not self.show_read:
continue
saved = progress.load(book_id)
last_opened = saved.updated_at if saved else 0.0
entries.append((last_opened, path, read))
entries.sort(key=lambda e: e[0], reverse=True)
self.visible_paths = [path for _, path, _ in entries]
list_view = self.query_one("#library-list", ListView)
list_view.clear()
if not self.paths:
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
return
if not entries:
list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen")))
return
for _, path, read in entries:
label = f"{path.stem}" if read else path.stem
list_view.append(ListItem(Label(label)))
list_view.index = 0
list_view.focus()
def action_toggle_read_filter(self) -> None:
self.show_read = not self.show_read
self._refresh_list()
state = "eingeblendet" if self.show_read else "ausgeblendet"
self.notify(f"Gelesene Bücher {state}.", timeout=2)
def action_open_selected(self) -> None:
list_view = self.query_one("#library-list", ListView)
if not self.visible_paths or list_view.index is None:
return
self.app.push_screen(ReaderScreen(self.visible_paths[list_view.index]))
def on_list_view_selected(self, event: ListView.Selected) -> None:
self.action_open_selected()
class DioraTuiApp(App):
CSS_PATH = "app.tcss"
TITLE = "diora-tui"
def __init__(self, library_dir: Path) -> None:
super().__init__()
self.library_dir = library_dir
def on_mount(self) -> None:
self.push_screen(LibraryScreen(self.library_dir))
async def action_quit(self) -> None:
cfg = config_mod.load()
if cfg is not None:
try:
await asyncio.to_thread(remote.sync_library, self.library_dir, cfg)
except remote.SyncError:
pass # best-effort — don't block quitting on a sync failure
self.exit()
def _prompt(label: str, *, secret: bool = False) -> str:
value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip()
if not value:
raise SystemExit(f"Abgebrochen: {label} darf nicht leer sein.")
return value
def _ask_yes_no(question: str) -> bool:
return input(f"{question} [y/N]: ").strip().lower() in ("y", "yes", "j", "ja")
def _prompt_enc_key() -> str:
print()
print("Verschlüsselungs-Key — zwei Wege:")
print(" [1] Aus Benutzername + Passwort ableiten (wie diora's 'Unlock'-Formular im Browser)")
print(" [2] Base64-Key direkt einfügen (z.B. per Browser-Konsole exportiert)")
choice = input("Wahl [1/2, Standard 1]: ").strip() or "1"
if choice == "2":
return _prompt("Verschlüsselungs-Key (Base64)", secret=True)
print(
"Hinweis: das liefert nur dann den richtigen Key, wenn dieser Account je über "
"diora's Passwort-Ableitung ('Unlock with password' im Browser) entsperrt wurde. "
"War der Key dort nur automatisch zufällig erzeugt, kommt hier ein anderer "
"(falscher) Key raus — 'sync' meldet das dann als Entschlüsselungsfehler, ohne "
"etwas kaputtzumachen; in dem Fall stattdessen [2] mit dem exportierten Key nutzen."
)
username = _prompt("diora-Benutzername")
password = _prompt("diora-Passwort", secret=True)
return crypto.derive_key_b64(username, password)
def _resolve_remote_config(server_override: str | None, *, save: bool) -> config_mod.RemoteConfig:
cfg = None if server_override else config_mod.load()
if cfg is not None:
return cfg
print("Keine gespeicherten Zugangsdaten gefunden — bitte einmalig eingeben.")
server_url = (server_override or _prompt("Server-URL (z.B. https://diora.creamfresh.xyz)")).rstrip("/")
token = _prompt("API-Token (diora → Einstellungen → /accounts/settings/)", secret=True)
enc_key = _prompt_enc_key()
cfg = config_mod.RemoteConfig(server_url=server_url, api_token=token, enc_key_b64=enc_key)
if save or _ask_yes_no("Zugangsdaten lokal speichern, damit du sie nicht erneut eingeben musst?"):
config_mod.save(cfg)
print("Gespeichert.")
return cfg
def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> None:
cfg = _resolve_remote_config(server_override, save=save)
print(f"Verbinde zu {cfg.server_url}")
try:
result = remote.sync_library(library_dir, cfg)
except remote.SyncError as e:
raise SystemExit(f"Fehler: {e}")
print(f"{len(result.downloaded)} Buch/Bücher neu heruntergeladen nach {library_dir}.")
for name in result.downloaded:
print(f" + {name}")
if result.unchanged:
print(f"{result.unchanged} Buch/Bücher bereits lokal vorhanden, übersprungen.")
if result.skipped_pdf:
titles = ", ".join(result.skipped_pdf)
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}")
if result.failed:
titles = ", ".join(result.failed)
print(f"{len(result.failed)} Buch/Bücher konnten nicht entschlüsselt werden (falscher Key?): {titles}")
if result.progress_pulled:
print(f"Lesefortschritt für {result.progress_pulled} Buch/Bücher vom Server übernommen.")
def main() -> None:
parser = argparse.ArgumentParser(
description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)"
)
parser.add_argument(
"--library",
type=Path,
default=DEFAULT_LIBRARY,
help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})",
)
subparsers = parser.add_subparsers(dest="command")
sync_parser = subparsers.add_parser("sync", help="EPUBs vom diora-Server holen und entschlüsseln")
sync_parser.add_argument("--server", help="Server-URL, überschreibt gespeicherte Zugangsdaten für diesen Lauf")
sync_parser.add_argument(
"--save", action="store_true", help="Eingegebene Zugangsdaten lokal speichern, ohne zu fragen"
)
args = parser.parse_args()
library_dir = args.library.expanduser()
if args.command == "sync":
run_sync(library_dir, args.server, save=args.save)
return
DioraTuiApp(library_dir).run()
if __name__ == "__main__":
main()

View file

@ -1,49 +0,0 @@
#library-list {
height: 1fr;
}
StatusBar {
dock: bottom;
height: 1;
padding: 0 1;
background: $panel;
color: $text-muted;
text-align: right;
}
#reader-scroll {
height: 1fr;
padding: 1 2;
align-horizontal: center;
}
#reader-loading {
height: 1fr;
}
FootnoteScreen {
align: center middle;
}
#footnote-body {
width: 80%;
max-width: 100;
height: auto;
max-height: 80%;
background: $panel;
border: round $primary;
padding: 1 2;
}
.footnote-note {
margin-bottom: 1;
}
#footnote-hint {
dock: bottom;
width: 80%;
max-width: 100;
background: $panel;
color: $text-muted;
padding: 0 2;
}

View file

@ -1,214 +0,0 @@
"""Whole-book flat block extraction, matching static/js/app.js's EPUB_BLOCK_SELECTOR
and getPositionAnchor()/restoreFromAnchor() exactly in *structure* (blockIndex is
purely DOM order, no rendering needed) so position anchors are comparable across
the web reader and this TUI. See CLAUDE.md's `tui/` section for the full picture.
innerFraction on the web is defined via getBoundingClientRect() pixel geometry,
which has no TUI equivalent a terminal has no font metrics/reflow the way a
browser does. We approximate it with row-based geometry from Rich's own text
wrapping (see reader.py), which is close enough because the server's "furthest
wins" comparison (_progress_is_further) only falls back to comparing fractions
when two anchors share the exact same block index block index is the primary,
exactly-reproducible signal.
Footnote references are detected with the same heuristic as app.js's
_looksLikeFootnoteLink: wrapped in/wrapping a <sup>, a class name containing
note/footnote/fn, or an epub:type="noteref" attribute.
"""
from __future__ import annotations
import re
import warnings
from dataclasses import dataclass, field
from pathlib import Path
import ebooklib
from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning
from ebooklib import epub
# EPUB content documents are XHTML; treating them as HTML (matching app.js's
# `new DOMParser().parseFromString(html, 'text/html')`, static/js/app.js:2638)
# is intentional, not a mistake — silence bs4's XML-vs-HTML nudge for it.
# lxml's HTML parser is ~3x faster than bs4's built-in html.parser, which
# matters here: some real-world EPUBs run to tens of thousands of blocks.
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
_PARSER = "lxml"
# Matches app.js's EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li,
# blockquote, dt, dd, figcaption, div:not(:has(*))'
_BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "dt", "dd", "figcaption"}
# Matches app.js's sanitizeEpubHtml() strip list (script/style are also stripped
# via regex before DOMParser even runs there; decomposing here is equivalent).
_STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"]
# Matches app.js's _looksLikeFootnoteLink's class-name check.
_FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b")
# Anchor format the server accepts (books/views.py:save_progress); anything else
# is silently discarded back to ''.
_ANCHOR_RE = re.compile(r"\d{1,7}:\d(\.\d{1,6})?")
@dataclass
class FootnoteRef:
marker: str # visible text of the reference link, e.g. "1"
offset: int # character offset into the block's text where the marker sits
target_id: str # fragment id to resolve against block ids
@dataclass
class Block:
text: str
tag: str
chapter_index: int
id: str | None = None
footnotes: list[FootnoteRef] = field(default_factory=list)
# Every id found anywhere in this block's subtree, not just on the block
# element itself — footnote *targets* are frequently an <a id="..."> or
# similar nested a level or two inside the actual containing paragraph
# (see showFootnotePopover's `.closest('.footnote') || .parentElement`
# walk-up in app.js), so a target id resolves to "the block containing
# it" rather than requiring the id to sit on the block tag itself.
ids: list[str] = field(default_factory=list)
@dataclass
class FlatBook:
id: str
title: str
author: str
path: Path
blocks: list[Block]
chapter_titles: list[str]
chapter_start_block: list[int] # blocks[chapter_start_block[i]] is chapter i's first block
footnote_targets: dict[str, int] # fragment id -> block index of its content
def _is_leaf_div(tag: Tag) -> bool:
return tag.name == "div" and tag.find(True) is None
def _looks_like_footnote_link(el: Tag, href: str) -> bool:
if "#" not in href:
return False
cls = " ".join(el.get("class") or []).lower()
if _FOOTNOTE_CLASS_RE.search(cls):
return True
epub_type = (el.get("epub:type") or "").lower()
if "noteref" in epub_type:
return True
return el.find_parent("sup") is not None or el.find("sup") is not None
def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
for child in el.children:
if isinstance(child, NavigableString):
out.append(str(child))
elif isinstance(child, Tag):
if child.name == "a":
href = child.get("href") or ""
if _looks_like_footnote_link(child, href):
marker = child.get_text(" ", strip=True)
offset = len("".join(out))
target_id = href.split("#", 1)[1] if "#" in href else ""
footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id))
out.append(marker)
continue
_walk_text(child, footnotes, out)
def _extract_blocks(html: bytes, chapter_index: int) -> list[Block]:
soup = BeautifulSoup(html, _PARSER)
for name in _STRIP_TAGS:
for el in soup.find_all(name):
el.decompose()
blocks: list[Block] = []
for el in soup.find_all(True):
if el.name in _BLOCK_TAGS or _is_leaf_div(el):
footnotes: list[FootnoteRef] = []
parts: list[str] = []
_walk_text(el, footnotes, parts)
text = re.sub(r"\s+", " ", "".join(parts)).strip()
ids = [tag_id for tag_id in (el.get("id"), *(d.get("id") for d in el.find_all(True))) if tag_id]
blocks.append(
Block(
text=text,
tag=el.name,
chapter_index=chapter_index,
id=el.get("id"),
footnotes=footnotes,
ids=ids,
)
)
return blocks
def load_flat_book(path: Path) -> FlatBook:
from .epub import _book_id # reuse the same path-hash id scheme
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
title_meta = raw.get_metadata("DC", "title")
title = title_meta[0][0] if title_meta else path.stem
author_meta = raw.get_metadata("DC", "creator")
author = author_meta[0][0] if author_meta else "Unbekannt"
blocks: list[Block] = []
chapter_titles: list[str] = []
chapter_start_block: list[int] = []
# app.js's parseEpub() includes every spine itemref unconditionally — no
# `linear` filtering (static/js/app.js:2623-2625) — since footnote/endnote
# targets are commonly parked in a linear="no" document. Skipping it here
# would both break footnote-target resolution and shift blockIndex
# numbering out of sync with the web reader for every block after it.
for idref, _linear in raw.spine:
item = raw.get_item_with_id(idref)
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
continue
chapter_index = len(chapter_titles)
chapter_blocks = _extract_blocks(item.get_content(), chapter_index)
chapter_start_block.append(len(blocks))
first_text = next((b.text for b in chapter_blocks if b.text), None)
chapter_titles.append((first_text or item.get_name())[:60])
blocks.extend(chapter_blocks)
footnote_targets: dict[str, int] = {}
for idx, b in enumerate(blocks):
for tag_id in b.ids:
footnote_targets.setdefault(tag_id, idx)
return FlatBook(
id=_book_id(path),
title=title,
author=author,
path=path,
blocks=blocks,
chapter_titles=chapter_titles,
chapter_start_block=chapter_start_block,
footnote_targets=footnote_targets,
)
def format_anchor(block_index: int, inner_fraction: float) -> str:
inner_fraction = max(0.0, min(1.0, inner_fraction))
return f"{block_index}:{inner_fraction:.6f}"
def parse_anchor(anchor: str) -> tuple[int, float] | None:
if not anchor or not _ANCHOR_RE.fullmatch(anchor):
return None
block_str, _, frac_str = anchor.partition(":")
return int(block_str), float(frac_str)
def anchor_is_further(new_anchor: str, old_anchor: str) -> bool:
"""Mirrors _progress_is_further / _cmpProgress (books/views.py, app.js)."""
new_parts = parse_anchor(new_anchor)
old_parts = parse_anchor(old_anchor)
if new_parts is None or old_parts is None:
return bool(new_anchor) and not old_anchor
nb, ni = new_parts
ob, oi = old_parts
return ni >= oi if nb == ob else nb >= ob

View file

@ -1,42 +0,0 @@
"""Line-API scroll view for the continuous reader — renders only the rows
actually visible on screen (via Widget.render_line), reading from the flat
Strip list diora_tui.layout.build_layout() precomputed once. This is what
keeps very large books responsive: nothing here scales with book size at
paint time, only with viewport height.
"""
from __future__ import annotations
from textual.geometry import Size
from textual.scroll_view import ScrollView
from textual.strip import Strip
from .layout import BookLayout
class ContinuousBookView(ScrollView):
# We always wrap text to fit exactly, so a horizontal scrollbar should
# never be needed — and "scroll" (not "auto") for the vertical one keeps
# its gutter reserved from the very first (still-empty) layout pass, so
# the width we wrap text at later never has to guess whether a scrollbar
# will appear and steal columns out from under already-wrapped lines.
DEFAULT_CSS = """
ContinuousBookView {
overflow-x: hidden;
overflow-y: scroll;
}
"""
def __init__(self, book_layout: BookLayout) -> None:
super().__init__()
self.book_layout = book_layout
self.virtual_size = Size(book_layout.width, book_layout.total_rows)
def render_line(self, y: int) -> Strip:
_scroll_x, scroll_y = self.scroll_offset
row = scroll_y + y
strips = self.book_layout.row_strips
width = self.scrollable_content_region.width
if row < 0 or row >= len(strips):
return Strip.blank(width, self.rich_style)
return strips[row].crop_extend(0, width, self.rich_style)

View file

@ -1,52 +0,0 @@
"""On-disk cache for the (FlatBook, BookLayout) pair a book open computes —
extraction + row-layout for very large books (tens of thousands of blocks)
can take several seconds each; reopening the same book at the same terminal
width should be near-instant instead of paying that cost again every time.
Not a correctness-critical cache: any miss (new book, different width, edited
file) just falls back to recomputing from scratch, so a stale/corrupt cache
entry is handled by overwriting it, never by crashing the reader.
"""
from __future__ import annotations
import hashlib
import pickle
from pathlib import Path
from platformdirs import user_cache_dir
from .blocks import FlatBook
from .layout import BookLayout
_CACHE_DIR = Path(user_cache_dir("diora-tui", "diora")) / "layout_cache"
def _cache_key(path: Path, width: int) -> str:
stat = path.stat()
raw = f"{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}|{width}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def load(path: Path, width: int) -> tuple[FlatBook, BookLayout] | None:
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
if not cache_file.exists():
return None
try:
with cache_file.open("rb") as f:
book, book_layout = pickle.load(f)
if not isinstance(book, FlatBook) or not isinstance(book_layout, BookLayout):
return None
return book, book_layout
except Exception:
return None
def save(path: Path, width: int, book: FlatBook, book_layout: BookLayout) -> None:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
try:
with cache_file.open("wb") as f:
pickle.dump((book, book_layout), f, protocol=pickle.HIGHEST_PROTOCOL)
except Exception:
pass # best-effort — a failed cache write shouldn't break reading

View file

@ -1,42 +0,0 @@
"""Local storage for diora server credentials used by `diora-tui sync`.
Mirrors the trust model of the web client's key storage (static/js/app.js,
getOrCreateEncKey/exportEncKey): the raw AES-256 key and API token are kept
in plaintext on disk, scoped to this machine/user (0600), with no additional
at-rest encryption same exposure as the browser's localStorage already has.
"""
from __future__ import annotations
import json
import stat
from dataclasses import asdict, dataclass
from pathlib import Path
from platformdirs import user_config_dir
_CONFIG_DIR = Path(user_config_dir("diora-tui", "diora"))
_CONFIG_FILE = _CONFIG_DIR / "config.json"
@dataclass
class RemoteConfig:
server_url: str
api_token: str
enc_key_b64: str
def load() -> RemoteConfig | None:
if not _CONFIG_FILE.exists():
return None
try:
data = json.loads(_CONFIG_FILE.read_text())
return RemoteConfig(**data)
except (json.JSONDecodeError, OSError, TypeError):
return None
def save(cfg: RemoteConfig) -> None:
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
_CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2))
_CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)

View file

@ -1,51 +0,0 @@
"""AES-256-GCM helpers matching static/js/app.js's encryptBytes/decryptBytes
(Web Crypto AES-GCM, 12-byte IV hex-encoded, ciphertext base64, key handled
as raw bytes) diora's books are end-to-end encrypted client-side, so the
server (and this module) only ever sees ciphertext plus the key the user
supplies out-of-band.
"""
from __future__ import annotations
import base64
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
_PBKDF2_ITERATIONS = 200_000
_KEY_LENGTH_BYTES = 32
class DecryptError(Exception):
"""Ciphertext could not be decrypted with the given key (wrong key, or corrupt data)."""
def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes:
try:
key = base64.b64decode(key_b64)
iv = bytes.fromhex(iv_hex)
ct = base64.b64decode(ciphertext_b64)
return AESGCM(key).decrypt(iv, ct, None)
except (InvalidTag, ValueError) as e:
raise DecryptError(str(e)) from e
def derive_key_b64(username: str, password: str) -> str:
"""Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does:
PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only
yields the key that actually decrypts a user's books if that account's
key was ever set up via diora's "unlock with password" flow — a browser
that only ever auto-generated a random key (the default) has a key this
can't reproduce.
"""
salt = f"diora:{username}".encode("utf-8")
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=_KEY_LENGTH_BYTES,
salt=salt,
iterations=_PBKDF2_ITERATIONS,
)
raw = kdf.derive(password.encode("utf-8"))
return base64.b64encode(raw).decode()

View file

@ -1,19 +0,0 @@
"""Library scanning and the stable per-book id shared across progress.py,
blocks.py, and remote.py."""
from __future__ import annotations
import hashlib
from pathlib import Path
def _book_id(path: Path) -> str:
# Identifies a book by its resolved path for now. Once the sync API exists,
# this should switch to whatever stable id diora assigns server-side.
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
def scan_library(library_dir: Path) -> list[Path]:
if not library_dir.exists():
return []
return sorted(library_dir.rglob("*.epub"))

View file

@ -1,90 +0,0 @@
"""Row-based layout for the continuous reader view — the TUI's terminal-grid
analogue of the browser's pixel-based getBoundingClientRect() geometry (see
blocks.py's module docstring for why blockIndex is exact but innerFraction is
only an approximation here).
Each block is wrapped independently at a known width, once, into a flat list
of pre-rendered Strip objects (one per terminal row) that book_view.py's
Line-API widget indexes directly in render_line() this is what makes very
large books (tens of thousands of blocks) open in seconds rather than
minutes: Textual only ever renders the rows actually on screen, instead of
laying out the whole book up front the way a single giant Static would.
"""
from __future__ import annotations
from dataclasses import dataclass
from rich.console import Console
from rich.text import Text
from textual.strip import Strip
_SEPARATOR_ROWS = 1 # one blank row between blocks, matching the old "\n\n".join style
@dataclass
class BookLayout:
starts: list[int] # row where block i begins
heights: list[int] # rendered row count for block i
row_strips: list[Strip] # one Strip per absolute row, len == total_rows
total_rows: int
width: int
def build_layout(block_texts: list[str], width: int, console: Console) -> BookLayout:
width = max(1, width)
starts: list[int] = []
heights: list[int] = []
row_strips: list[Strip] = []
blank = Strip.blank(width)
row = 0
for text in block_texts:
starts.append(row)
wrapped_lines = list(Text(text).wrap(console, width)) if text else []
if not wrapped_lines:
wrapped_lines = [Text("")]
heights.append(len(wrapped_lines))
for line in wrapped_lines:
strip = Strip(line.render(console), None).adjust_cell_length(width)
row_strips.append(strip)
row_strips.append(blank)
row += len(wrapped_lines) + _SEPARATOR_ROWS
return BookLayout(starts=starts, heights=heights, row_strips=row_strips, total_rows=row, width=width)
def get_position_anchor(layout: BookLayout, scroll_y: int) -> tuple[int, float]:
"""Mirrors app.js's getPositionAnchor(): the last block whose top row is at
or above scroll_y, or the first block if none has scrolled that far yet."""
n = len(layout.heights)
if n == 0:
return 0, 0.0
best_index = 0
found = False
for i in range(n):
if layout.heights[i] < 1:
continue
if layout.starts[i] > scroll_y:
break
best_index = i
found = True
if not found:
best_index = next((i for i in range(n) if layout.heights[i] >= 1), 0)
top = layout.starts[best_index]
height = max(1, layout.heights[best_index])
inner_fraction = max(0.0, min(1.0, (scroll_y - top) / height))
return best_index, inner_fraction
def scroll_y_for_anchor(layout: BookLayout, block_index: int, inner_fraction: float) -> int:
"""Mirrors app.js's restoreFromAnchor()."""
n = len(layout.heights)
if n == 0:
return 0
idx = max(0, min(block_index, n - 1))
top = layout.starts[idx]
height = layout.heights[idx]
return top + round(inner_fraction * height)

View file

@ -1,43 +0,0 @@
"""Local per-book metadata pulled from the server — currently just "read"
status (EBook.is_read from the /api/sync/ snapshot). Kept separate from
progress.py (reading position): different concern, different cadence this
is only ever set by `diora-tui sync`, never by the reader itself.
"""
from __future__ import annotations
import json
from pathlib import Path
from platformdirs import user_data_dir
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
_META_FILE = _DATA_DIR / "library.json"
def _load_all() -> dict[str, dict]:
if not _META_FILE.exists():
return {}
try:
return json.loads(_META_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
def _save_all(data: dict[str, dict]) -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
_META_FILE.write_text(json.dumps(data, indent=2))
def is_read(book_id: str) -> bool:
entry = _load_all().get(book_id)
return bool(entry and entry.get("is_read"))
def set_many(read_status: dict[str, bool]) -> None:
if not read_status:
return
data = _load_all()
for book_id, read in read_status.items():
data.setdefault(book_id, {})["is_read"] = read
_save_all(data)

View file

@ -1,68 +0,0 @@
"""Local reading-progress storage.
Positions are stored as the same "blockIndex:innerFraction" anchor string the
server uses (books/models.py, EBookProgress.save_progress) see
diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js
exactly, and anchor_is_further() for the identical "furthest wins" comparison
used server-side (_progress_is_further in books/views.py). A saved position
only ever advances (unless forced), so an older/offline run can't regress a
further-along read position matching that same rule locally.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from pathlib import Path
from platformdirs import user_data_dir
from .blocks import anchor_is_further
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
_PROGRESS_FILE = _DATA_DIR / "progress.json"
@dataclass
class Position:
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000"
updated_at: float = 0.0
def _load_all() -> dict[str, dict]:
if not _PROGRESS_FILE.exists():
return {}
try:
return json.loads(_PROGRESS_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
def _save_all(data: dict[str, dict]) -> None:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
_PROGRESS_FILE.write_text(json.dumps(data, indent=2))
def load(book_id: str) -> Position | None:
entry = _load_all().get(book_id)
if not isinstance(entry, dict) or not entry.get("anchor"):
return None
return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0))
def save(book_id: str, position: Position, *, force: bool = False) -> bool:
"""Returns True if the position was actually written (i.e. it was further
along, or forced) callers that push to the server only need to do so
when this returns True."""
data = _load_all()
existing = data.get(book_id)
if existing is not None and not force:
old_anchor = existing.get("anchor", "")
if not anchor_is_further(position.anchor, old_anchor):
return False
if not position.updated_at:
position.updated_at = time.time()
data[book_id] = {"anchor": position.anchor, "updated_at": position.updated_at}
_save_all(data)
return True

View file

@ -1,286 +0,0 @@
"""Continuous whole-book reader screen — mirrors the web reader's single
scrollable view (see blocks.py's module docstring) instead of the old
per-chapter pagination, so reading position is expressed in the same
"blockIndex:innerFraction" anchor space as the server and the web client.
Uses book_view.ContinuousBookView (a Line-API widget) rather than dumping the
whole book into one Static: for large books (tens of thousands of blocks) a
single giant renderable made Textual's layout/paint pass take upwards of a
minute, whereas the Line API only ever renders the rows on screen.
"""
from __future__ import annotations
import bisect
import re
from pathlib import Path
from rich.console import Console
from textual import work
from textual.binding import Binding
from textual.containers import Container, VerticalScroll
from textual.geometry import Size
from textual.screen import ModalScreen, Screen
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
from . import api, blocks, cache, config as config_mod, layout, progress
from .book_view import ContinuousBookView
from .statusbar import StatusBar
MAX_LINE_WIDTH = 120
AUTOSAVE_INTERVAL = 5.0
# ContinuousBookView forces its vertical scrollbar always-on (see book_view.py)
# so this stays constant — measuring the container's width and subtracting
# this fixed amount avoids a measure-after-constrain race against book_view's
# own (possibly already-constrained-from-a-previous-layout) width.
SCROLLBAR_GUTTER = 2
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
# Downloaded-via-sync files are named "<server-id> - <title>.epub" (see
# remote.py) — reused here to find the server book id for progress push.
_SERVER_ID_RE = re.compile(r"^(\d{4,7}) - ")
def _server_book_id(path: Path) -> int | None:
m = _SERVER_ID_RE.match(path.name)
return int(m.group(1)) if m else None
class FootnoteScreen(ModalScreen[None]):
BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")]
def __init__(self, notes: list[str]) -> None:
super().__init__()
self._notes = notes
def compose(self):
with VerticalScroll(id="footnote-body"):
for note in self._notes:
yield Static(note, classes="footnote-note")
yield Label("Escape/f zum Schließen", id="footnote-hint")
def action_dismiss_self(self) -> None:
self.dismiss(None)
class ReaderScreen(Screen):
BINDINGS = [
Binding("j,down", "scroll_down_line", "Runter", show=False),
Binding("k,up", "scroll_up_line", "Hoch", show=False),
Binding("n", "next_chapter", "Nächstes Kapitel"),
Binding("p", "prev_chapter", "Vorheriges Kapitel"),
Binding("f", "peek_footnote", "Fußnote"),
Binding("q,escape", "back", "Zurück zur Bibliothek"),
]
def __init__(self, path: Path) -> None:
super().__init__()
self.path = path
self.book: blocks.FlatBook | None = None
self.book_layout: layout.BookLayout = _EMPTY_LAYOUT
self._server_id = _server_book_id(path)
self._remote_cfg = config_mod.load() if self._server_id is not None else None
self._last_pushed_anchor = ""
def compose(self):
yield Header()
yield LoadingIndicator(id="reader-loading")
with Container(id="reader-scroll") as scroll_container:
self._scroll_container = scroll_container
# Stays visible (empty) from the start rather than toggling display
# on once loaded — a widget that's just been switched from hidden
# to visible hasn't been through a layout pass yet, so its `.size`
# is still (0, 0) and an immediate scroll_to() right after has
# nothing to clamp against and silently resets to 0.
self._book_view = ContinuousBookView(_EMPTY_LAYOUT)
yield self._book_view
yield StatusBar()
yield Footer()
def on_mount(self) -> None:
# Deferred rather than read synchronously here: right after mount the
# container hasn't been through a layout pass yet, so its size isn't
# reliable — same lesson as the scroll-restore race below.
self.call_after_refresh(self._start_loading)
def _start_loading(self) -> None:
if self._scroll_container.size.width == 0:
# Not sized yet after all — keep deferring instead of guessing a
# fixed number of refresh cycles (mirrors _restore_position below).
self.call_after_refresh(self._start_loading)
return
self._load_book(self._content_width())
@work(thread=True)
def _load_book(self, width: int) -> None:
cached = cache.load(self.path, width)
if cached is not None:
book, book_layout = cached
else:
book = blocks.load_flat_book(self.path)
console = Console(width=width)
book_layout = layout.build_layout([b.text for b in book.blocks], width, console)
cache.save(self.path, width, book, book_layout)
self.app.call_from_thread(self._on_book_loaded, book, book_layout, width)
def _on_book_loaded(self, book: blocks.FlatBook, book_layout: layout.BookLayout, width: int) -> None:
self.book = book
self.sub_title = book.title
self._apply_layout(book_layout, width)
self.query_one("#reader-loading", LoadingIndicator).display = False
self.call_after_refresh(self._restore_position)
self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
def _content_width(self) -> int:
# Measuring the *container* rather than book_view's own size avoids a
# measure-after-constrain race: once a layout has been applied,
# book_view's width is pinned to a previous value via styles.width
# (below), so re-measuring book_view itself on a later resize would
# just read that stale pinned width back instead of the new
# available space. The container's width is unaffected by that.
available = self._scroll_container.size.width - SCROLLBAR_GUTTER
return max(20, min(MAX_LINE_WIDTH, available))
def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None:
self.book_layout = book_layout
# Pin book_view's outer width to content-width-plus-scrollbar so its
# *inner* content region (what render_line actually draws into) ends
# up exactly `width` — matching what block texts were wrapped at.
self._book_view.styles.width = width + SCROLLBAR_GUTTER
self._book_view.book_layout = book_layout
self._book_view.virtual_size = Size(width, book_layout.total_rows)
self._book_view.refresh()
def _build_layout(self) -> None:
assert self.book is not None
width = self._content_width()
console = Console(width=width)
block_texts = [b.text for b in self.book.blocks]
new_layout = layout.build_layout(block_texts, width, console)
cache.save(self.path, width, self.book, new_layout)
self._apply_layout(new_layout, width)
def on_resize(self) -> None:
if self.book is None:
return
old_y = self._book_view.scroll_y
anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y)
self._build_layout()
new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac)
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
def _restore_position(self, attempt: int = 0) -> None:
if self.book is None:
return
saved = progress.load(self.book.id)
if saved is None:
return
parsed = blocks.parse_anchor(saved.anchor)
if parsed is None:
return
block_index, inner_fraction = parsed
y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction)
if y <= 0:
return
self._book_view.scroll_to(y=y, animate=False, immediate=True)
# A widget that's only just become part of the layout doesn't always
# honor an immediate scroll on the first attempt (its own size/scroll
# bounds can still be mid-update) — verify it actually landed and
# retry a bounded number of times rather than guessing a fixed delay.
if attempt < 20 and abs(self._book_view.scroll_y - y) > 1:
self.call_after_refresh(lambda: self._restore_position(attempt + 1))
def _current_anchor_str(self) -> str:
block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
return blocks.format_anchor(block_index, inner_fraction)
def _autosave(self) -> None:
self._save_progress()
def _save_progress(self) -> None:
if self.book is None or not self.book_layout.heights:
return
anchor = self._current_anchor_str()
advanced = progress.save(self.book.id, progress.Position(anchor=anchor))
if advanced and self._remote_cfg is not None and self._server_id is not None:
self._push_remote_progress(anchor)
@work(thread=True, exclusive=True, group="progress-push")
def _push_remote_progress(self, anchor: str) -> None:
if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None:
return
try:
api.post_progress(
self._remote_cfg.server_url,
self._remote_cfg.api_token,
self._server_id,
scroll_fraction=0.0,
position_anchor=anchor,
force=False,
)
self._last_pushed_anchor = anchor
except Exception:
pass # best-effort — local progress is already saved regardless
def on_unmount(self) -> None:
self._save_progress()
def action_scroll_down_line(self) -> None:
self._book_view.scroll_relative(y=1, animate=False)
def action_scroll_up_line(self) -> None:
self._book_view.scroll_relative(y=-1, animate=False)
def _current_chapter_index(self) -> int:
if self.book is None:
return 0
block_index, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
return bisect.bisect_right(self.book.chapter_start_block, block_index) - 1
def _jump_to_block(self, block_index: int) -> None:
y = layout.scroll_y_for_anchor(self.book_layout, block_index, 0.0)
self._book_view.scroll_to(y=y, animate=False, immediate=True)
def action_next_chapter(self) -> None:
if self.book is None:
return
chapter = self._current_chapter_index()
if chapter + 1 < len(self.book.chapter_start_block):
self._jump_to_block(self.book.chapter_start_block[chapter + 1])
self._save_progress()
def action_prev_chapter(self) -> None:
if self.book is None:
return
chapter = self._current_chapter_index()
if chapter > 0:
self._jump_to_block(self.book.chapter_start_block[chapter - 1])
self._save_progress()
def action_peek_footnote(self) -> None:
if self.book is None or not self.book_layout.heights:
return
current_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
forward = [(i, b) for i, b in enumerate(self.book.blocks) if i >= current_block and b.footnotes]
backward = [(i, b) for i, b in enumerate(self.book.blocks) if i < current_block and b.footnotes]
candidate = forward[0] if forward else (backward[-1] if backward else None)
if candidate is None:
self.notify("Keine Fußnote in diesem Buch gefunden.", timeout=3)
return
_, block = candidate
notes: list[str] = []
for ref in block.footnotes:
target_idx = self.book.footnote_targets.get(ref.target_id)
if target_idx is None:
continue
notes.append(self.book.blocks[target_idx].text)
if not notes:
self.notify("Fußnote konnte nicht aufgelöst werden.", timeout=3)
return
self.app.push_screen(FootnoteScreen(notes))
def action_back(self) -> None:
self.app.pop_screen()

View file

@ -1,110 +0,0 @@
"""Fetch + decrypt books from a diora server into the local TUI library, and
pull reading progress for them into the local progress store.
Progress is pull-only here (server -> local); the reader pushes local ->
server itself while a book is open (see reader_screen.py). Both directions
use the same "blockIndex:innerFraction" anchor format as the web reader
(diora_tui/blocks.py) and the same furthest-wins merge rule
(diora_tui/progress.py, books/views.py's _progress_is_further) — a pull can
only ever advance local progress, never regress it, unless forced.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
import requests
from . import api, crypto, library_meta
from .config import RemoteConfig
from .epub import _book_id
from .progress import Position, save as save_progress
class SyncError(Exception):
pass
@dataclass
class SyncResult:
downloaded: list[str] = field(default_factory=list)
unchanged: int = 0
skipped_pdf: list[str] = field(default_factory=list)
failed: list[str] = field(default_factory=list)
progress_pulled: int = 0
def _sanitize_filename(name: str) -> str:
name = re.sub(r"[^\w\s.-]", "_", name).strip()
return (name or "buch")[:80]
def _parse_meta(raw: bytes) -> dict:
return json.loads(raw.decode("utf-8"))
def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
try:
snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token)
except api.ApiError as e:
raise SyncError(str(e)) from e
except requests.RequestException as e:
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
library_dir.mkdir(parents=True, exist_ok=True)
result = SyncResult()
local_path_by_server_id: dict[int, Path] = {}
for book in snapshot.get("books", []):
book_id = book["id"]
try:
meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"]))
except crypto.DecryptError:
result.failed.append(f"#{book_id}")
continue
if meta.get("type") == "pdf":
result.skipped_pdf.append(meta.get("title") or f"#{book_id}")
continue
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None)
if existing is not None:
result.unchanged += 1
local_path_by_server_id[book_id] = existing
continue
try:
data = api.fetch_book_data(cfg.server_url, cfg.api_token, book_id)
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
except (api.ApiError, crypto.DecryptError, requests.RequestException):
result.failed.append(meta.get("title") or f"#{book_id}")
continue
title = meta.get("title") or f"book-{book_id}"
dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub"
dest.write_bytes(raw)
result.downloaded.append(dest.name)
local_path_by_server_id[book_id] = dest
for entry in snapshot.get("book_progress", []):
anchor = entry.get("position_anchor") or ""
if not anchor:
continue # PDF-only scroll_fraction progress — this TUI is EPUB-only
dest = local_path_by_server_id.get(entry.get("book_id"))
if dest is None:
continue
local_id = _book_id(dest)
if save_progress(local_id, Position(anchor=anchor)):
result.progress_pulled += 1
read_status = {
_book_id(path): bool(book.get("is_read"))
for book in snapshot.get("books", [])
if (path := local_path_by_server_id.get(book["id"])) is not None
}
library_meta.set_many(read_status)
return result

View file

@ -1,44 +0,0 @@
"""Bottom status bar: battery level + current time, refreshed every second.
Battery is read via psutil (cross-platform); on a desktop machine with no
battery, psutil.sensors_battery() returns None and the bar just shows time.
"""
from __future__ import annotations
from datetime import datetime
from textual.widgets import Static
try:
import psutil
except ImportError:
psutil = None # battery display degrades gracefully to time-only
class StatusBar(Static):
def on_mount(self) -> None:
self._refresh()
self.set_interval(1.0, self._refresh)
def _refresh(self) -> None:
self.update(self._render_text())
def _render_text(self) -> str:
parts = []
battery = self._battery_text()
if battery:
parts.append(battery)
parts.append(datetime.now().strftime("%H:%M:%S"))
return " ".join(parts)
def _battery_text(self) -> str | None:
if psutil is None:
return None
try:
battery = psutil.sensors_battery()
except Exception:
return None
if battery is None:
return None
state = "lädt" if battery.power_plugged else "Akku"
return f"{state} {round(battery.percent)}%"

View file

@ -1,25 +0,0 @@
[project]
name = "diora-tui"
version = "0.1.0"
description = "Terminal-EPUB-Reader für diora mit Server-Sync für Bücher und Lesefortschritt."
requires-python = ">=3.9"
dependencies = [
"textual>=0.60",
"ebooklib>=0.18",
"beautifulsoup4>=4.12",
"lxml>=5.0",
"platformdirs>=4.0",
"requests>=2.31",
"cryptography>=42.0",
"psutil>=5.9",
]
[project.scripts]
diora-tui = "diora_tui.app:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["diora_tui"]