Compare commits

..

85 commits

Author SHA1 Message Date
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
marwin
30a6d29ca8 Sync-API für lokale Clients: Personal-Access-Token + GET /api/sync/
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 23s
Ermöglicht Apps außerhalb des Browsers (z.B. den TUI-Client), sich per
Authorization: Bearer <token> zu authentifizieren statt per Session-Cookie.
Die neue ApiTokenAuthMiddleware setzt request.user genau wie ein Login,
wodurch alle bestehenden books/podcasts/radio-Endpunkte ohne Änderungen
token-fähig werden. GET /api/sync/ liefert zusätzlich den kompletten
Nutzerzustand (Bücher-Metadaten, Lesefortschritt, Notizen, Podcasts,
Sender) in einem Request; Schreiben läuft weiter über die bestehenden
Endpunkte, um deren Merge-Semantik (furthest-wins Progress, Notes-Upsert)
wiederzuverwenden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 16:20:54 +02:00
marwin
bb143a249b Reader: Toast-Optik repariert — Button-Font und doppelter Textschatten (SW v35)
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
.reader-toast-btn hatte keine font-family gesetzt; Buttons erben das
anders als normaler Text nicht vom body, dadurch fiel "Zurückspringen"
auf die System-Schrift statt Courier New zurück.

.reader-toast/.reader-toast-action erbten außerdem den globalen
Halo-Textschatten (--outline), der eigentlich Text gegen wechselnde
Hintergründe absetzen soll. Die Toasts tauschen Vorder-/Hintergrund
aber bewusst (weiße Pille, schwarzer Text) und sitzen bereits auf
eigenem, blickdichtem Hintergrund — der geerbte Schatten hatte
dadurch exakt die Textfarbe und verdickte die Schrift nur unnötig,
statt einen Kontrast-Effekt zu erzielen. Jetzt wie die übrigen
Elemente mit eigenem farbigen Hintergrund (.btn-play etc.) explizit
mit text-shadow:none von diesem Effekt ausgenommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:02:05 +02:00
marwin
5053d0036a Reader: j/k-Scrollen ohne Ramp-up, an feste Kadenz statt Display-Hz gekoppelt (SW v34)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 17s
Die requestAnimationFrame-Schleife aus dem letzten Fix lief mit der tatsächlichen
Bildwiederholrate des Displays statt einer festen Rate — auf Laptops mit
120/144/165Hz-Panels bedeutete das 2-3x mehr scrollBy-Aufrufe (und damit
Scroll-Listener-Durchläufe) pro Sekunde als beabsichtigt, was sich als Lag
statt als Beschleunigung äußerte. Zurück zu setInterval, aber mit fester
20ms-Kadenz unabhängig vom Display und dt-kompensierter, konstanter
Geschwindigkeit (~1200px/s, kein Ramp-up mehr).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:50:23 +02:00
marwin
e70bb881cc Reader: schnelleres j/k-Scrollen; Offline-Modus auf Desktop repariert (SW v33)
All checks were successful
Build and push Docker image / build (push) Successful in 50s
Test / test (push) Successful in 16s
j/k nutzte ein starres 8px/16ms-setInterval (~500px/s, unsauber getaktet
gegenüber dem Frame-Takt) — ersetzt durch eine requestAnimationFrame-Schleife
mit sanftem Ramp-up (700 -> 1800px/s über 300ms), das behebt sowohl die zu
niedrige Geschwindigkeit als auch das Ruckeln kurz nach Scrollbeginn.

Der Service Worker war unter /static/js/sw.js registriert, wodurch sein
Scope (mangels Service-Worker-Allowed-Header) automatisch auf /static/js/
beschränkt war statt die ganze App abzudecken — Navigationen zu z.B. /
liefen nie durch den fetch-Handler, weshalb offline nur die Browser-eigene
Fehlerseite statt der App-Shell erschien. Jetzt wird sw.js zusätzlich unter
der Root /sw.js ausgeliefert (diora/urls.py) und dort mit explizitem
scope:'/' registriert; alte Registrierungen mit dem falschen Scope werden
beim Laden automatisch entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:31:01 +02:00
marwin
d3c435dd59 Reader: Notizen auf Mobile schnell setzbar statt über Randspalte (SW v32)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 15s
Auf schmalen Screens saß der Marker-Modus-Button bisher nur in der 150px-
Randspalte — man musste sie erst öffnen (und damit den ohnehin knappen
Lesebereich verkleinern), um überhaupt markieren zu können. Jetzt zusätzlich
ein Marker-Button direkt im Haupt-Header (nur <=600px sichtbar, Desktop
bleibt bei der bisherigen Anordnung in der Notes-Leiste).

Im Marker-Modus legt ein einfacher Tap auf reinen Lesetext (ohne Auswahl)
jetzt direkt eine freie Notiz an der Wortposition an — man muss nicht mehr
den exakten Klick-Punkt in der Randspalte treffen. Das Bearbeiten der
Notiz läuft auf schmalen Viewports über ein neues Bottom-Sheet (volle
Breite) statt über das Inline-Textfeld in der 150px-Spalte, das auf dem
Handy-Keyboard unangenehm zu bedienen ist.

Notiz-Objekte werden jetzt erst bei tatsächlich eingegebenem Text angelegt
und gespeichert (vorher: sofort beim Setzen des Ankers) — verhindert, dass
ein abgebrochener/leerer Tap eine Ghost-Notiz in currentHighlights
hinterlässt. Gilt für alle Eingabewege (Bottom-Sheet UND die bestehende
Inline-Randspalten-Bearbeitung), per Playwright verifiziert inkl. Escape-
Cancel auf Desktop.

Priorität laut Nutzer: Notizen-Erfassung muss auf Mobile gut funktionieren;
dass bestehende Notizen dort weniger prominent sichtbar sind, ist explizit
in Ordnung — die vertiefte Auseinandersetzung mit Notizen bleibt eine
Desktop-Aufgabe.

Verifiziert per Playwright (390×844 mobil + 1280×900 Desktop-Regression):
Marker-Button-Sichtbarkeit, Bottom-Sheet-Flow (Erstellen/Abbrechen/Speichern,
kein Ghost-Eintrag), reale Text-Selektion + Highlight+Notiz weiterhin
funktionsfähig auf Mobile, Desktop-Workflow (Randspalte, Inline-Editor,
Escape-Cancel, Blur-Commit) unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 20:13:55 +02:00
marwin
76068f6ed8 Reader: Fußnoten-Popover erkennt jetzt mehr EPUB-Konventionen (SW v31)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 14s
Die Erkennung von Fußnoten-Links war auf class="footnote" + gleiche-Datei-
Anker (href startsWith '#') hartkodiert. Andere Editionen markieren Fußnoten
anders — z.B. die e-artnow-Ausgabe von Marx' "Das Kapital" (zeno.org-Konvertierung)
nutzt gar keine Klasse und verlinkt mit einem <sup> als Kind (statt Vorfahre)
des <a> auf eine ANDERE Kapiteldatei ("../Text/Karl0045.html#F537") — dadurch
griff weder die Klassen- noch die Hash-Prüfung, das Popover blieb tot.

_looksLikeFootnoteLink() erkennt jetzt zusätzlich epub:type="noteref" (EPUB3-
Standard), jede Klasse mit "note"/"footnote"/"fn" im Namen, und als Fallback
jeden Link mit Fragment-Anker, der irgendwo mit einem <sup> verschachtelt ist
(in beide Richtungen) — der in EPUBs praktisch universelle Marker für
Fußnotenziffern. Die Fragment-Extraktion nimmt jetzt alles nach dem letzten
'#' statt nur bei href.startsWith('#'), da das Buch als eine zusammenhängende
DOM gerendert wird und getElementById dateiübergreifend funktioniert.

Verifiziert per Playwright: Marx-Edition zeigt jetzt beim Hover 1326 erkannte
Fußnoten-Links mit korrektem Popover-Inhalt; Žižeks "Weniger als nichts"
(vorher funktionierend, class="footnote"-basiert) weiterhin unverändert
funktionsfähig — keine Regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 18:55:48 +02:00
marwin
42bc090a28 Reader: Marker-Farben mit color:inherit statt UA-Default-Schwarz (SW v30)
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 15s
mark-Elemente haben browserseitig einen UA-Default (color:black), der bislang
nicht überschrieben wurde — beim Markieren von Text im dunklen Standard-Theme
(weißer Fließtext) sprang die Markierung dadurch abrupt auf schwarzen Text.
Jetzt color:inherit, damit markierter Text immer die Farbe des umgebenden
Texts behält; die vier Hintergrundfarben sind dafür kräftiger/dunkler gewählt,
sodass sie in allen drei Themes (dunkel/weißer Text, Sepia, Hell) ausreichend
Kontrast zum inherited Text bieten (WCAG-Kontrastwerte 4.8:1–11.7:1, per
Playwright-Screenshot in allen drei Themes gegengeprüft).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 18:44:09 +02:00
marwin
b805d62b11 Books: Notizen-Sidebar auto-öffnen, Notizen-Download + Gelesen-Status in Übersicht (SW v29)
All checks were successful
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 16s
- EBook.is_read (Migration) + neuer /books/<id>/read/-Endpoint zum Togglen
- book_list liefert is_read und has_highlights (Existenz einer Highlights-Zeile)
- Reader öffnet die Notizen-Sidebar automatisch, wenn das Buch bereits
  Markierungen/Notizen hat, statt sie hinter dem manuellen Toggle zu verstecken
- Buch-Übersicht: Download-Button für Notizen/Markierungen direkt aus der Liste
  (ohne den Reader zu öffnen), "Als gelesen markieren" mit Bestätigungsdialog,
  Filter "Gelesene Bücher anzeigen" (standardmäßig aus)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 18:10:33 +02:00
marwin
f9573d3d62 Reader: lange Notizen wurden im Rand zu früh ausgeblendet (SW v28)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 15s
_repositionMarginMarkers() filterte Einträge anhand eines festen ±60px-
Fensters um den reinen Ankerpunkt, bevor die tatsächliche Kartenhöhe bekannt
war. Für Notizen mit mehr Text als eine "Standard"-Karte reichte das nicht:
die Karte hätte teilweise schon sichtbar sein müssen, obwohl ihr Anker noch
weiter außerhalb lag — sie blieb beim Scrollen also länger verschwunden als
erwartet.

Vorfilter jetzt großzügig (mind. 400px bzw. die Höhe des sichtbaren
Bereichs), danach ein zweiter Cull-Durchgang mit den tatsächlich gemessenen
Kartenhöhen nach dem Stapeln — dadurch werden nur Einträge entfernt, die
wirklich komplett außerhalb liegen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:09:02 +02:00
marwin
9648730be6 Reader: Prozentanzeige mit Nachkommastelle bei langen Büchern (SW v27)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 15s
Bei sehr langen Büchern (ab ca. 1 Mio. Zeichen, entspricht etwa 600-700
Druckseiten) bewegt sich die ganzzahlige Prozentanzeige beim Lesen kaum noch
— eine Nachkommastelle macht sie wieder aussagekräftig. Betrifft die
Fortschrittsanzeige im Reader-Header sowie die "≈X% im Buch"-Koordinate bei
Annotationen-Export und -Verzeichnis.

Formatierung über toLocaleString() (Komma bei de, Punkt bei en, etc.). Das
Fortschrittsfeld ist dafür von <input type="number"> auf type="text" mit
inputmode="decimal" umgestellt — number-Inputs erzwingen intern immer einen
Punkt, unabhängig von der Locale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:51:05 +02:00
marwin
7bfce034ba Reader: Marker-Modus statt impliziter Auswahl, Notiz-Anker rein positional (SW v26)
All checks were successful
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 16s
Vier vom Nutzer gemeldete Bugs behoben:

- Markierter Text war kaum lesbar: der globale weiße Text-Outline (fürs
  Hintergrundbild-Feature gedacht) kollidierte mit der Highlight-Farbe.
  Reader sitzt immer auf eigenem, opakem Hintergrund — Outline dort komplett
  deaktiviert.
- "Notiz zu Markierung hinzufügen" öffnete weiterhin die rechte Sidebar statt
  den Rand. Jeder Notiz-Eintragspunkt (Highlight+Notiz aus der Textauswahl,
  Edit-Note-Klick auf eine Markierung im Fließtext) läuft jetzt über
  _openMarginAndEditNote() — öffnet bei Bedarf den Rand und bearbeitet dort
  inline. openNoteEditor()/rechte Sidebar für Notizen komplett entfernt.
- Kernursache für "Notiz verschwindet, Markierung heftet sich an falsche
  Notiz": Anker basierten auf XPath mit Sibling-Index unter den Textknoten
  eines Blocks — jede weitere Markierung im selben Block (surroundContents
  spaltet Textknoten) verschob diese Indizes und korrumpierte lautlos ALLE
  anderen Anker im selben Block. Anker adressieren jetzt stattdessen
  Block-Index + Zeichen-Offset innerhalb des Blocks (wie das bestehende
  Lesefortschritt-Ankersystem) — Wrapping/Unwrapping durch andere
  Markierungen ändert weder Blockreihenfolge noch Zeichenanzahl, ist also
  erschütterungsfrei für andere Anker im selben Block.
- Löschen einer Markierung riss zuvor aus demselben Grund weiter unten
  liegende Notizen optisch mit raus (Daten blieben in der DB) — mit dem
  neuen Ankersystem behoben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:28:19 +02:00
marwin
3f40a4078a Reader: Notiz-Eingabe direkt im Rand statt Sprung zur Sidebar (SW v25)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 14s
Notizen setzen/bearbeiten passiert jetzt inline an der Stelle im Notizen-Rand,
wo die Notiz auch angezeigt wird — kein Wechsel mehr zur rechten Sidebar.
Textfeld wird beim Öffnen automatisch fokussiert und ausgewählt. Enter
speichert, Escape verwirft die Änderung.

Stift-Button (Marker-Modus) zieht in die Titelzeile des Notizen-Rands um,
neben "Notes" — vorher musste man dafür ständig zur Hauptleiste zurück, die
im Immersive-Modus eingeklappt ist.

Nebenbei zwei Bugs beim Testen gefunden und behoben: ein Klick auf leere
Randfläche während ein Editor bereits offen war, legte durchs Blur-Event
gleichzeitig eine zweite, ungewollte Notiz an; und Escape im Notizfeld
bubbled zum globalen Escape-Handler durch, der dann den ganzen Reader
schloss (und damit die gerade bearbeiteten Highlights aus dem Speicher warf).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 11:23:16 +02:00
marwin
a2f4be8e1e Reader: Positions-Koordinate im Export + Verzeichnis für Markierungen/Notizen (SW v24)
Echte Buchseiten sind fürs Erste zurückgestellt (kein Konzept dafür bei
reflowable EPUB-Text) — stattdessen bekommt jeder Export-Eintrag jetzt eine
grobe Prozent-Koordinate ("≈37% im Buch"), zusätzlich zur Kapitelgruppierung.

Neu: ein Verzeichnis für Markierungen/Notizen im selben Sidebar-Stil wie die
Lesezeichen (Button in der Margin-Kopfzeile), das alle Einträge in
Lesereihenfolge mit Vorschau und Koordinate auflistet und per Klick an die
jeweilige Textstelle springt — anders als die Margin-Leiste, die nur zeigt,
was gerade sichtbar ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 11:10:56 +02:00
marwin
bc180daab0 Reader: Marker-Modus statt impliziter Auswahl, Notiz-Anker rein positional (SW v23)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 14s
Drei Nachbesserungen am Annotationen-Feature nach Live-Test:

- Neuer Stift-Button (Marker-Modus) in der Kopfleiste: Textauswahl löst das
  Highlight-Popup nur noch aus, wenn der Modus aktiv ist — vorher war die
  Funktion unauffindbar (impliziter "einfach Text markieren"-Gestus ohne
  sichtbaren Einstiegspunkt) und hätte jede Textauswahl (auch zum Kopieren)
  gekapert.
- Freiform-Notizen bekommen keine Textdekoration mehr im Lesefluss — der
  Anker dient nur der Positionierung im Rand, nicht der optischen Markierung.
- Die Margin-Sidebar zeigt Notiztext jetzt direkt lesbar als kleine
  Post-it-Karte an der zugehörigen Stelle (mit Stapel-Logik gegen
  Überlappung), statt nur ein abstraktes Symbol zum Anklicken. Reine
  Highlights ohne Notiz bleiben ein kleiner Farbpunkt.
- Highlight-Popover/Tooltip klammern ihre Position jetzt hart an den
  Viewport, damit eine Auswahl nahe Seitenrand nicht unsichtbar außerhalb
  des sichtbaren Bereichs landet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 10:49:30 +02:00
marwin
090aec9c60 Reader: Annotationen — Marker, freie Notizen, Margin-Sidebar, Export (SW v22)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 15s
Textauswahl→Highlight-Popover war implementiert, aber nie verdrahtet (kein
mouseup/touchend-Listener) — als Voraussetzung mit behoben. Neu: freiformige
Notizen ohne Textmarkierung, verankert per Klick in einer neuen linken
Margin-Sidebar (Notiz am ersten Wort der Zeile auf Klickhöhe); Sidebar zeigt
Marker für alle Highlights/Notizen im sichtbaren Bereich und schiebt den
Lesetext nach rechts. Export sammelt alle Markierungen/Notizen eines Buchs in
Lesereihenfolge, nach Kapitel gruppiert, als Textdatei. EPUB-only — PDFs haben
noch keinen Text-Layer (siehe Issue #10).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 10:17:01 +02:00
marwin
17e205b6f0 Reader/UI: mehrere Fixes + Playwright-Setup (SW v21)
All checks were successful
Build and push Docker image / build (push) Successful in 55s
Test / test (push) Successful in 15s
- Books: Reader sprang beim Scroll+sofortigem Tap auf die alte Position
  zurück (Anker-Tracking lief nur alle 2s über den Save-Debounce, jetzt
  eigener 150ms-Tracker unabhängig davon)
- Books: +/- Buttons neben den Reader-Slidern (Font/Zeilenhöhe/Breite)
- Books: Sicherheitsnetz-Toast bei plötzlichen großen Scroll-Sprüngen
  (Fling, aus Versehen 'G' gedrückt) mit Zurückspringen-Option; bewusste
  Sprünge (TOC, Lesezeichen, Fortschritts-Eingabe, Suche) lösen ihn nicht aus
- Radio: Donation-Hinweis kam bei vielen Lieblingssendern gefühlt ständig —
  jetzt zusätzlich auf ~1-von-10 Plays gedrosselt
- UI: farbige Emoji-Icons (🔍📻💡 etc.) durch monochrome Unicode-Symbole
  ersetzt, die nicht mehr je nach OS/Browser-Font unterschiedlich aussehen;
  Lesezeichen-Icon war zudem fast identisch zum TOC-Icon
- UI: native alert()/confirm()/prompt() durch eigenes Modal-System ersetzt
- UI: Hash-basiertes URL-Routing für Tabs (#radio/saved, #podcasts/feeds, …)
  — Browser-Zurück/Vor und Deep-Links funktionieren jetzt
- Tests: Playwright e2e-Setup gegen isolierte Test-DB (DIORA_DB_NAME),
  Smoke- und Auth-Specs

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 09:01:52 +02:00
marwin
daffd0002b Books: Fortschritt-Konflikt via Anker-Blockindex statt scroll_fraction (SW v15)
All checks were successful
Build and push Docker image / build (push) Successful in 1m1s
Test / test (push) Successful in 15s
Die max()-Semantik aus v14 verglich nur die grobe scroll_fraction. Da
scroll_fraction und position_anchor gemeinsam, aber entkoppelt gespeichert
werden, konnte ein einzelner Save mit transient zu hoher fraction (z.B.
instabiles Layout beim Laden) die Position dauerhaft einfrieren: jeder
spätere echte Fortschritt hatte eine kleinere fraction und wurde inklusive
Anker verworfen — der Leser landete immer wieder an derselben Stelle.

Fix: Vergleich läuft jetzt über den Blockindex im Anker (die Größe, die
auch die Sprungposition bestimmt), mit innerFraction als Tiebreaker.
scroll_fraction dient nur noch als Fallback für PDFs ohne Anker. Server
(_progress_is_further) und Client (_cmpProgress) nutzen identische Logik;
loadBookList-Push und lokaler Cache ebenfalls. Bestehende eingefrorene
Einträge entfrieren sich beim ersten Weiterlesen selbst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:52:27 +02:00
marwin
29f6cc4413 Books: Fortschritt-Konflikt via max() lösen — weiter = gewinnt (SW v14)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 15s
Der am weitesten gelesene Wert hat immer Vorrang:
- Server: scroll_fraction = max(neu, aktuell) — ein afk-Fenster bei 10%
  kann nicht mehr 80% überschreiben
- Client loadBookList: wenn lokaler Fortschritt > Server, wird gepusht
  und für die Anzeige verwendet (deckt Offline-Lesen ab)
- Pending-Flag-Logik entfernt — nicht mehr nötig dank max-Semantik
- saveReaderProgress(force=true): neuer force-Parameter schreibt
  Position unabhängig vom gespeicherten Maximum; Button ⚑ im
  Reader-Header für den Ausnahmefall "zurück-zum-Anfang"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 15:41:35 +02:00
marwin
28756ffdc2 Books: Fortschritt-Sync robuster — Pending-Flag statt Timestamp (SW v13)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 18s
Timestamp-Vergleich in _saveBookMeta entfernt: client-seitige
ISO-Strings (new Date().toISOString) lagen fast immer leicht vor dem
Serverwert und haben dadurch regelmässig korrekten Online-Fortschritt
überschrieben.

Neu: explizites Pending-Flag (localStorage diora_pending_progress) —
gesetzt wenn saveReaderProgress offline scheitert, gelöscht nach
erfolgreicher Synchronisierung.

- _saveBookMeta nutzt das Flag statt Zeitstempel: Server-Daten
  überschreiben lokalen Fortschritt nur wenn kein Pending-Flag gesetzt
- _syncPendingProgress: pusht ausstehenden Fortschritt beim
  online-Event bevor loadBookList läuft
- saveReaderProgress setzt das Flag nur wenn !navigator.onLine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 15:29:38 +02:00
marwin
b097eea600 Books: offline-Fortschritt wird nicht überschrieben (SW v12)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 16s
- _saveBookMeta vergleicht local vs. Server last_read — neuere lokale
  Daten (offline gelesen) überleben den nächsten Server-Abruf
- loadBookList pusht beim Online-Werden automatisch lokal neueren
  Fortschritt an den Server bevor die Liste gerendert wird
- saveBookmarks/saveHighlights markieren fehlgeschlagene Saves als
  pending sync (localStorage); beim nächsten online-Event werden alle
  pending Annotationen zuerst gepusht, dann erst die Buchliste geladen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 07:22:32 +02:00
marwin
5d69edc57e Layout: Tab-Leiste position:fixed + padding-top korrekt (SW v11)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 16s
Statt padding-top gegen die Navbar zu kämpfen ist #tabs jetzt
position:fixed direkt unterhalb der Navbar (top:var(--nav-h),
z-index:150). Damit ist die Leiste garantiert immer sichtbar,
unabhängig vom Scroll-Zustand oder Offline-Status.

main-content padding-top auf calc(var(--nav-h)+3rem) erhöht um
Platz für Navbar+Tabs zu schaffen. Offline-Banner vereinfacht zu
einer body-Klasse (kein fixed DOM-Element mehr).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:34:53 +02:00
marwin
d7a27b188d Layout + SW: Tab-Leiste sichtbar + kein veralteter Cache
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 15s
- main-content padding-top auf calc(var(--nav-h) + 0.75rem) erhöht
  damit die Tab-Leiste (Radio/Focus/Podcasts/Books) sicher unterhalb
  der fixed Navbar liegt — auf Desktop und Mobile
- SW v10: sendet SW_ACTIVATED an alle offenen Tabs wenn ein neuer SW
  übernimmt; app.js lädt daraufhin neu (außer wenn ein Buch geöffnet
  ist) — verhindert dass der Browser eine uralte HTTP-gecachte Version
  ausliefert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:19:00 +02:00
marwin
aa562f8b4d Books: offline UX — Banner, Auto-Tab, klare Fehlermeldung
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 16s
- Bei Offline-Start oder Wechsel in Flugzeugmodus: roter Banner
  unterhalb der Navbar, automatischer Wechsel zum Books-Tab (einziger
  sinnvoller Tab offline), Upload-Zone wird ausgeblendet
- Bei Rückkehr online: Banner verschwindet, Upload-Zone erscheint wieder,
  Buchliste wird neu synchronisiert
- openBook unterscheidet jetzt zwischen "nicht gecacht" (Toast-Hinweis
  zum einmaligen Online-Öffnen) und echtem Defekt (markBookBroken wie
  bisher) — kein stilles Scheitern mehr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:12:22 +02:00
marwin
a96591c63f SW: cache navigation response offline (v9)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 14s
Ohne diesen Fix schlägt das Öffnen der App im Flugzeug fehl, weil der
Service Worker die HTML-Seite (/) nicht cached — der Browser zeigt
dann eine Netzwerkfehler-Seite bevor JS überhaupt startet.

Strategie: Network-first für Navigationsanfragen, bei Netzwerkfehler
die zuletzt gecachte Version ausliefern. Nach dem ersten Online-Besuch
funktioniert die App vollständig offline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 23:58:07 +02:00
marwin
5ba1a7bdad Books: request persistent storage when first caching a book
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 15s
Ohne navigator.storage.persist() ist der IndexedDB-Speicher "best-effort"
und kann vom Browser bei Speicherknappheit still gelöscht werden. Der
Aufruf erfolgt beim ersten Schreiben eines Buches in den Cache — Chrome
gewährt das für installierte PWAs automatisch, Firefox kann einen Dialog
zeigen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 23:54:54 +02:00
marwin
3d25519367 PWA: fix icon purpose entries + bump SW cache to v8
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 17s
Chrome 98+ hat Probleme mit kombinierten purpose-Werten ("any maskable")
auf einem einzelnen Icon-Eintrag. Separate Einträge für "any" und
"maskable" sind zuverlässiger und entsprechen der Spec-Empfehlung.
SW-Version auf v8 erhöht, um nach den JS-Änderungen einen sauberen
Cache-Neustart zu erzwingen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 23:49:32 +02:00
marwin
5504e5c627 Books: fix footnote popover background + strip EPUB title tooltips
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 16s
Popover had transparent background because --bg-card is explicitly
transparent; now uses hardcoded colors with theme-aware overrides.
Also strips title attributes during EPUB sanitization to prevent
native browser tooltips from appearing while reading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:21:43 +02:00
marwin
bfa0439aa1 Books: vollständiger Offline-Modus für EPUB-Reader
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 17s
- IndexedDB v2 mit zwei neuen Stores: book_meta (Buchliste +
  Lesefortschritt) und book_annotations (Lesezeichen & Highlights)
- loadBookList fällt bei Netzwerkfehler auf lokalen Meta-Cache zurück,
  blendet Upload-Zone aus und zeigt Offline-Hinweis
- Leseposition (scroll_fraction + position_anchor) wird bei jedem
  Auto-Save lokal gecacht, sodass offline-Öffnen am richtigen Ort
  landet
- Lesezeichen und Highlights werden nach Server-Abruf lokal gecacht
  und bei Netzwerkfehler von dort geladen; Änderungen offline bleiben
  bis zur nächsten Synchronisierung erhalten
- Mobile Reader-Header: kompaktere Abstände, Fortschritts-Input auf
  kleinen Screens ausgeblendet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:14:40 +02:00
marwin
159aa6f340 Books: footnote hover popover for EPUB reader
All checks were successful
Build and push Docker image / build (push) Successful in 20s
Test / test (push) Successful in 16s
Hovering over a footnote reference shows the footnote content in a
floating popover; moving the mouse away dismisses it. On mobile, a
tap toggles the popover with an ✕ close button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:04:47 +02:00
marwin
fe91000e7c Books: repair flow also re-encrypts metadata from file
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 14s
replace-data endpoint now optionally accepts meta_ct/meta_iv.
The client extracts title/author from the uploaded file (same as
normal upload), re-encrypts metadata with the current key, and sends
both data and meta together — so broken books are fully restored
including their displayed title and author.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:06:52 +02:00
marwin
4b47f6e67a Books: broken-book repair flow + simplify password change
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 16s
When a book fails to open, a '!' button appears next to it in the list.
Clicking it prompts for the original file; the file is re-encrypted with
the current key and replaces the broken ciphertext on the server while
keeping all metadata, progress, highlights and bookmarks intact.

- Add POST /books/<pk>/replace-data/ endpoint (updates data only)
- Add _evictCachedBook() to clear IndexedDB cache for a single book
- Replace complex client-side re-encryption on password change with a
  simple confirm() warning that books will need to be re-uploaded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:03:59 +02:00
marwin
817323ad19 Accounts: fix password change key migration
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 15s
The old key comparison was wrong — the localStorage key may have been
randomly generated rather than PBKDF2-derived. Fix:
- Add /accounts/check-password/ to validate the old password server-side
  before touching any keys
- Use the localStorage key directly as the old decryption key (it is
  always the correct source of truth, regardless of how it was generated)
- Derive the new key from the new password via PBKDF2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:46:03 +02:00
marwin
1426c69a73 Accounts: re-encrypt all books on password change
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 15s
The encryption key is PBKDF2-derived from the login password. Changing
the password without migrating the key would make all books undecryptable
on the next login.

- Add POST /books/<pk>/rekey/ endpoint to replace a book's ciphertexts
- Password change form is now JS-driven: before submitting to the server,
  derives the old key, verifies it matches localStorage, derives the new
  key, re-encrypts all books (data + meta) and their highlights/bookmarks,
  updates localStorage, then submits the Django form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:37:41 +02:00
marwin
e3a9c61c05 Accounts: add password change form to settings
All checks were successful
Build and push Docker image / build (push) Successful in 57s
Test / test (push) Successful in 15s
Uses Django's built-in PasswordChangeForm and update_session_auth_hash
so the session stays valid after the change. Form is hidden in a
<details> element and opens automatically on validation errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:26:07 +02:00
marwin
c064d0d4e1 EPUB reader: constrain images to text width
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
Prevents wide chapter images from causing horizontal overflow/scroll
on mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:12:06 +02:00
marwin
d64f159044 EPUB reader: add 'Kein Fett' toggle to suppress book bold
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 13s
Publisher EPUBs often set font-weight via <b>/<strong> tags or inline
style attributes. A new toggle in the settings panel adds the
.reader-no-bold class which overrides all font weights to normal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 11:52:45 +02:00
marwin
2d488fd542 EPUB reader: font picker + performance fixes for large books
All checks were successful
Build and push Docker image / build (push) Successful in 1m17s
Test / test (push) Successful in 14s
- Add font family selector (Serif/Sans/Verdana/Mono) to reader settings panel,
  saved per book in localStorage
- Strip <style>/<script> blocks before regex and DOMParser processing to avoid
  working over large base64-embedded font CSS in publisher EPUBs
- Parallelize image blob URL creation with Promise.all instead of sequential await
- Inject chapter HTML progressively in batches with requestAnimationFrame yields
  to keep the UI responsive when loading many-chapter books

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 11:40:58 +02:00
marwin
9241d6170b Remove loading overlay from re-renders (zoom/spread)
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
Current page renders almost instantly, no overlay needed.
Overlay stays for initial book open only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:55:13 +02:00
marwin
1cf3f730ea Render PDF from current page outward on zoom/re-render
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 16s
renderPdf now takes an optional pivotPage. Pass 1 builds the full DOM
and sizes all canvases (instant). Then scrolls to the pivot page
immediately so the user stays in place. Pass 2 renders pixels from
pivot→end, then pivot-1→start.

reRenderPdf detects the current visible page before re-rendering and
passes it as pivot, so zoom no longer resets scroll position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:50:01 +02:00
marwin
20a1b9a889 Fix scroll position lost after PDF zoom re-render
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 16s
Scroll position was restored before browser finished layout, so
scrollHeight was still wrong. Now waits two animation frames after
reRenderPdf completes before restoring position.

Also shows loading overlay during zoom re-renders, not just on initial load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:44:49 +02:00
marwin
89cb70392a j/k scroll continuously while held, stop on keyup
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
Uses a 16ms interval (~60fps) instead of per-event smooth scroll,
so holding j/k scrolls fluidly. d/u/f/b remain one-shot with smooth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:32:32 +02:00
marwin
b6619f6465 Fix PDF loading overlay disappearing too early
All checks were successful
Test / test (push) Successful in 18s
Build and push Docker image / build (push) Successful in 14s
Moved overlay out of contentEl (which renderPdf clears immediately)
into the reader-overlay element. It now stays visible for the entire
render duration and is removed only after renderPdf resolves.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:29:46 +02:00
marwin
d607388bad Add vim-style keyboard shortcuts to reader
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 17s
j/k    scroll down/up (small step, ~8% viewport)
d/u    scroll half page down/up
f/b    scroll full page down/up
g/G    jump to top/bottom
n/p    next/prev page (PDF scroll mode)

Keys are ignored when an input or textarea is focused.
Existing arrow key navigation in paginated PDF mode unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:20:06 +02:00
marwin
bbb982d0b1 PDF loading overlay: dark translucent instead of solid black
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:09:52 +02:00
marwin
846b299713 Show dark loading overlay while PDF is rendering
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 16s
Displays a dark background with a spinning indicator in the reader
content area before renderPdf starts. renderPdf clears innerHTML
itself when it begins, so no explicit cleanup needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:08:44 +02:00
marwin
d839a8f8a3 Dark scrollbar styling
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:06:20 +02:00
marwin
1fdf5f9b30 Remove double scrollbar in Chrome PWA
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
html/body no longer scroll — overflow: hidden on both.
.main-content gets height: calc(100% - nav-h) and overflow-y: auto
so it's the single scroll container. The reader overlay is
position: fixed so it's unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:01:23 +02:00
marwin
8a3aedb0e7 Use Window Controls Overlay for Chrome PWA titlebar
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 16s
Adds display_override: window-controls-overlay to the manifest so
the app extends into the titlebar area. The navbar becomes draggable
and uses env(titlebar-area-*) to avoid the window controls buttons.
Interactive elements stay clickable via -webkit-app-region: no-drag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:54:00 +02:00
marwin
def879de2d Fix radio sidebar play buttons broken by JSON.stringify double quotes
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 16s
Inline onclick='playStation("url", "name", null)' broke because
JSON.stringify wraps strings in double quotes, conflicting with the
onclick attribute quotes. Replaced with data attributes + addEventListener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:32:05 +02:00
marwin
f5c141626f Fix blurry PDF rendering when zoomed in Chrome PWA
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 17s
Zoom is now baked into the canvas render scale instead of being applied
via CSS zoom. CSS zoom only scales pixels already on the canvas, causing
blurriness at high DPR (e.g. PWA on mobile). Now the canvas is rendered
at the correct resolution for the chosen zoom level.

Also: stop nulling currentPdfDoc on re-render so PDF.js page cache is
preserved, making zoom re-renders significantly faster.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:14:24 +02:00
marwin
e5dc58d84f Replace focus station sidebar with compact radio player sidebar
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
The 📻 button now opens a sidebar with the currently playing station/track,
play/stop + volume control, and the saved stations list. The old focus
station configuration UI (presets, custom URL input, save-to-server) and
the auto-play-on-book-open behavior are removed.

Closes #6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:22:07 +02:00
marwin
554ca93e30 Close reader settings panel when tapping content to hide bars
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:13:28 +02:00
marwin
6b419c6fe0 Save reader settings per book in localStorage
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 17s
Each book gets its own key (diora_reader_settings_{bookId}).
Opening a book resets to defaults then applies book-specific overrides,
so zoom, spread, invert, theme etc. are remembered independently per book.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:53:38 +02:00
marwin
916e8a568b Add PDF two-page spread mode and mobile pinch-to-zoom
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 16s
Spread mode: new toggle in reader settings renders pages side-by-side
(cover alone, then pairs 2-3, 4-5...) using full screen width. Each
page scales to half the container. Navigation and scroll position
tracking are unchanged since per-page IDs are preserved.

Pinch-to-zoom: captures 2-finger pinch on the PDF reader, blocking
native browser zoom. Live CSS zoom during gesture, snaps to nearest
10% step on release. Single-finger scroll unaffected. applyPdfZoom
moved to module level so touch handlers can call it. Touch listeners
cleaned up on reader close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:18:25 +02:00
marwin
e9c5b8058b Centralize remaining magic numbers in settings.py
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
- HIGHLIGHTS_MAX_BYTES, BOOKMARKS_MAX_BYTES: books size limits now in settings
- PODCAST_INBOX_PAGE_SIZE: shared between podcasts/views.py and app.js via DIORA_CONFIG
- VOLUME_DEFAULT: radio stream player default volume
- ITUNES_TIMEOUT: unified iTunes API timeout (was 5s vs 6s inconsistency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:10:14 +02:00
marwin
0a6ba6feac Inject upload size limits from settings into frontend via DIORA_CONFIG
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 15s
Backend settings are now the single source of truth for EBOOK_MAX_BYTES
and BG_MAX_BYTES. A new context processor exposes them to all templates,
and base.html injects them as window.DIORA_CONFIG so app.js reads from
there instead of hardcoded values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:05:51 +02:00
marwin
9c0a046c57 Fix ebook upload: new books appear at top, 50 MB limit frontend/backend
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 17s
- Create EBookProgress on upload so new books get a last_read timestamp
  and sort to the top of the book list
- Update frontend file size check from 10 MB to 50 MB
- Fix backend error message to say 50 MB instead of 10 MB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:01:32 +02:00
marwin
4274f49971 Increased ebook upload limit to 50 MB
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
2026-04-04 20:48:18 +02:00
marwin
2ba613fdd8 Reconnect audio automatically on device change
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 15s
Listens for navigator.mediaDevices.devicechange and re-routes the
audio element to the new default output device without a page reload.
Debounced 500ms to handle BT device bursts. Podcasts resume at the
correct timestamp via loadedmetadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 02:27:18 +02:00
marwin
aff4f5aef2 Mobile: hide volume, add PDF zoom +/- buttons, fix zoom scroll position
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
- Hide volume label/slider/input on mobile (≤600px)
- Add − and + buttons flanking the PDF zoom slider
- Preserve scroll fraction across zoom changes in PDF scroll mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:22:04 +02:00
marwin
1bda59e3fc Fix PDF position restore: timer after restore, add scroll-mode case
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 16s
- Move auto-save timer setup to after position restore so an early
  visibilitychange can't overwrite the saved position with 0
- Add missing restore path for non-paginated PDF scroll mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:46:57 +02:00
marwin
68bb7b5920 Add IndexedDB book cache and fix PDF position on mobile
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 15s
- Cache encrypted book data in IndexedDB (cache-first on open)
- Evict books not read for 4 weeks on book list load
- Fix PDF paginated mode not activating on book open (mobile)
- enterPdfPaginatedMode() now called after position restore in openBook()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:10:03 +02:00
marwin
dbe3b46f3e Simplify immersive reader: tap centre to toggle bars
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 15s
Replace timer/edge-hover system with a simple click-to-toggle.
Tapping interactive elements (buttons, links, settings) is ignored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 15:50:31 +02:00
marwin
2448586050 Fix PDF rendering blur on high-DPI/mobile screens
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 14s
Scale canvas by devicePixelRatio and constrain CSS size to logical
pixels so PDF pages render crisp on Retina and mobile displays.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 15:48:44 +02:00
marwin
1af07c7952 Add anchor-based reading position tracking
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 15s
Replaces scroll_fraction-only position tracking with element-based
anchors ("{index}:{innerFraction}"). Position is now stable across
font size changes and different screen sizes. A ResizeObserver
restores the anchor on viewport/orientation changes.

Falls back to scroll_fraction for books without a saved anchor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 15:41:30 +02:00
marwin
0c6846e71f Give navbar and now-playing-bar solid background
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 15:24:35 +02:00
marwin
bef8fbc8d8 Revert "Remove immersive reader mode, bars stay always visible"
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
This reverts commit bdb6857c73.
2026-04-01 15:24:00 +02:00
marwin
bdb6857c73 Remove immersive reader mode, bars stay always visible
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 15:23:10 +02:00
marwin
44d51d3a7f Fix gpodder: add update_urls to episode actions POST response
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 16s
AntennaPod's GpodnetEpisodeActionPostResponse expects update_urls
in the episode upload response, not just timestamp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 19:08:46 +01:00
marwin
a205eafd79 Fix gpodder sync: return update_urls in subscription GET response
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 15s
AntennaPod throws JSONException when update_urls is missing from the
subscription list response. Return the change-format object instead
of a plain array for GET requests without a since parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:15:36 +01:00
marwin
2859464b14 Open HTTP streams in new tab to bypass mixed content
All checks were successful
Build and push Docker image / build (push) Successful in 11s
Test / test (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 11:45:40 +01:00
marwin
da300b54c7 Revert: remove HTTP stream new-tab workaround
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 10:54:48 +01:00
marwin
f040a45325 Open HTTP streams in raw new tab instead of custom HTTPS player
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 14s
The custom /radio/stream-player/ page is served over HTTPS, so the browser
still applies mixed-content restrictions and upgrades http:// audio to https://,
which fails for streams without TLS support.

Fix: window.open(url, '_blank') navigates the tab directly to the HTTP URL.
The tab itself is then HTTP, bypassing mixed-content restrictions entirely.
Browser plays the stream natively with its built-in audio player.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 10:48:28 +01:00
marwin
9e08079dec Fix stream player autoplay: handle blocked play() promise
All checks were successful
Build and push Docker image / build (push) Successful in 11s
Test / test (push) Successful in 17s
window.open() doesn't transfer the user gesture to the new tab, so autoplay
is blocked. Previously play().catch(()=>{}) swallowed the error silently while
setting playing=true, leaving the UI in a broken state.

Now: if play() rejects, reset state and show "Click Play to start". The user's
click on the Play button IS a user gesture, so it succeeds on the second attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 10:39:43 +01:00
marwin
ee8cfd8314 Fix immersive reader: extend top zone to cover visible bar heights
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 16s
When top bars are showing, use their actual combined offsetHeight (+12px buffer)
as the hide threshold instead of the fixed 60px. This prevents the bars from
disappearing while the mouse moves from the edge zone down to click header buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 19:01:37 +01:00
marwin
0037fd8db4 Reader immersive mode: bars slide out after 5s, return on edge hover/tap
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 15s
After opening a book, a 5-second timer adds body.reader-immersive which:
- Slides navbar up (translateY(-100%))
- Slides now-playing bar down (translateY(100%))
- Expands reader overlay to full viewport (top:0, bottom:0)
- Collapses reader header (max-height:0)

Mouse near top edge (<60px): shows navbar + reader header
Mouse near bottom edge (<60px): shows now-playing bar
Touch at top/bottom edge: shows respective bar for 3s then hides again
closeReader() cleans up all classes and event listeners

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:04:11 +01:00
marwin
85776390f6 Open HTTP streams in minimal standalone player tab
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 16s
Instead of trying a HTTPS upgrade (which fails for IP-based streams):
- playStation() detects http:// URL on https:// page, opens /radio/stream-player/
  with url, name, vol as query params, then returns — main stream is already
  stopped by the stopPlayback(false) call at the top of playStation()
- New view stream_player renders a standalone minimal player page
- Template: auto-plays on load, correct volume from URL param, volume changes
  synced back to localStorage so main window picks it up next time,
  live track metadata via SSE, tab title updates on track change,
  close-tab button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:50:12 +01:00
marwin
83304c197d Try HTTPS upgrade for HTTP streams, show error on failure
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 16s
Browsers block HTTP (mixed content) audio from HTTPS pages. On playStation,
if the URL is http:// and page is https://, try the https:// version first.
If the stream fails to load, show a clear error in the track display instead
of silently doing nothing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:22:51 +01:00
marwin
38451514c2 Remove text marking and PDF pagination from master (moved to testing branch)
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 15s
- Remove mouseup highlight selection listener
- Remove Paginated button from PDF settings panel
- Remove pagination auto-enable on book open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:42:03 +01:00
39 changed files with 5656 additions and 431 deletions

View file

@ -3,3 +3,8 @@ DEBUG=True
AMAZON_AFFILIATE_TAG=diora-20
LASTFM_API_KEY=
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

11
.gitignore vendored
View file

@ -33,3 +33,14 @@ CLAUDE.md
# OS
.DS_Store
Thumbs.db
antennapod-feeds-2026-03-19.opml
.gitignore
playlist.m3u
# Playwright / Node
node_modules/
test-results/
playwright-report/
blob-report/
e2e/.auth/
data/e2e_test.sqlite3

25
accounts/middleware.py Normal file
View file

@ -0,0 +1,25 @@
from .models import ApiToken
class ApiTokenAuthMiddleware:
"""Authenticates requests carrying `Authorization: Bearer <token>` as the
owning user, for local/native clients that can't hold a Django session.
Must run after AuthenticationMiddleware. Leaves request.user untouched
(AnonymousUser) on missing/invalid tokens existing view-level auth
checks (`_require_auth`, `login_required`, `is_authenticated`) already
handle that case with a 401/redirect, so there's nothing to do here."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.user.is_authenticated:
auth = request.META.get('HTTP_AUTHORIZATION', '')
if auth.startswith('Bearer '):
token = auth[len('Bearer '):].strip()
if token:
api_token = ApiToken.objects.select_related('user').filter(token=token).first()
if api_token is not None:
request.user = api_token.user
return self.get_response(request)

View file

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-08-15 14:01
import accounts.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('accounts', '0003_userprofile_background_encrypted_and_more'),
]
operations = [
migrations.CreateModel(
name='ApiToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(db_index=True, default=accounts.models._generate_token, max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='api_token', to=settings.AUTH_USER_MODEL)),
],
),
]

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

82
accounts/sync.py Normal file
View file

@ -0,0 +1,82 @@
from django.db.models import Max
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from books.models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
from podcasts.models import PodcastFeed, EpisodeProgress, PodcastQueue
from radio.models import SavedStation
@require_http_methods(['GET'])
def sync_snapshot(request):
"""Aggregate read-only snapshot of a user's data for local/native clients
(see accounts/middleware.py for the Bearer-token auth that makes this
reachable without a browser session).
Deliberately excludes book file bytes and podcast audio those stay
large/expensive and are fetched lazily via the existing per-resource
endpoints (e.g. GET /books/<id>/data/), which this same token also
unlocks. Writes (progress, highlights, bookmarks, ...) go through those
existing endpoints too, to reuse their merge semantics rather than
duplicating it here."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
user = request.user
books = list(EBook.objects.filter(user=user).values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read'))
for b in books:
b['uploaded_at'] = b['uploaded_at'].isoformat()
book_progress = list(
EBookProgress.objects.filter(user=user).values('book_id', 'scroll_fraction', 'position_anchor', 'updated_at')
)
for p in book_progress:
p['updated_at'] = p['updated_at'].isoformat()
book_highlights = list(EBookHighlights.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at'))
for h in book_highlights:
h['updated_at'] = h['updated_at'].isoformat()
book_bookmarks = list(EBookBookmarks.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at'))
for bm in book_bookmarks:
bm['updated_at'] = bm['updated_at'].isoformat()
podcast_feeds = list(
PodcastFeed.objects.filter(user=user)
.annotate(latest_episode_at=Max('episodes__pub_date'))
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at')
)
for f in podcast_feeds:
for key in ('last_refreshed_at', 'added_at', 'latest_episode_at'):
if f[key]:
f[key] = f[key].isoformat()
episode_progress = list(
EpisodeProgress.objects.filter(user=user)
.values('episode_id', 'position_seconds', 'played', 'dismissed', 'updated_at')
)
for ep in episode_progress:
ep['updated_at'] = ep['updated_at'].isoformat()
podcast_queue = list(
PodcastQueue.objects.filter(user=user).order_by('position').values('episode_id', 'position')
)
saved_stations = list(
SavedStation.objects.filter(user=user)
.values('id', 'name', 'url', 'bitrate', 'country', 'tags', 'favicon_url', 'is_favorite', 'notes')
)
return JsonResponse({
'server_time': timezone.now().isoformat(),
'books': books,
'book_progress': book_progress,
'book_highlights': book_highlights,
'book_bookmarks': book_bookmarks,
'podcast_feeds': podcast_feeds,
'episode_progress': episode_progress,
'podcast_queue': podcast_queue,
'saved_stations': saved_stations,
})

528
accounts/tests.py Normal file
View file

@ -0,0 +1,528 @@
import json
import socket
from types import SimpleNamespace
from unittest.mock import patch
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
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
class ApiTokenAuthMiddlewareTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.token = ApiToken.objects.create(user=self.user)
def test_valid_bearer_token_authenticates(self):
resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
self.assertEqual(resp.status_code, 200)
def test_missing_token_is_unauthenticated(self):
resp = self.client.get('/books/')
self.assertEqual(resp.status_code, 401)
def test_invalid_token_is_unauthenticated(self):
resp = self.client.get('/books/', HTTP_AUTHORIZATION='Bearer not-a-real-token')
self.assertEqual(resp.status_code, 401)
def test_token_scoped_to_owning_user(self):
other = User.objects.create_user(username='bob', password='pw12345678')
EBook.objects.create(user=other, meta_ct='ct', meta_iv='iv', data_ct='ct', data_iv='iv')
resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
self.assertEqual(resp.json(), [])
def test_regenerate_invalidates_old_token(self):
old = self.token.token
self.token.regenerate()
resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {old}')
self.assertEqual(resp.status_code, 401)
resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
self.assertEqual(resp.status_code, 200)
def test_regenerate_view_requires_login_and_rotates_token(self):
resp = self.client.post('/accounts/api-token/regenerate/')
self.assertEqual(resp.status_code, 302)
self.assertIn('/accounts/login/', resp.url)
self.client.login(username='alice', password='pw12345678')
old = self.token.token
resp = self.client.post('/accounts/api-token/regenerate/')
self.assertRedirects(resp, '/accounts/settings/')
self.token.refresh_from_db()
self.assertNotEqual(self.token.token, old)
class SyncSnapshotTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.token = ApiToken.objects.create(user=self.user)
self.book = EBook.objects.create(
user=self.user, meta_ct='meta-ct', meta_iv='meta-iv', data_ct='data-ct', data_iv='data-iv',
)
EBookProgress.objects.create(
user=self.user, book=self.book, scroll_fraction=0.5, position_anchor='3:0.5',
)
EBookHighlights.objects.create(user=self.user, book=self.book, ct='hl-ct', iv='hl-iv')
def test_requires_auth(self):
resp = self.client.get('/api/sync/')
self.assertEqual(resp.status_code, 401)
def test_snapshot_shape_and_data(self):
resp = self.client.get('/api/sync/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
self.assertEqual(resp.status_code, 200)
data = resp.json()
for key in (
'server_time', 'books', 'book_progress', 'book_highlights', 'book_bookmarks',
'podcast_feeds', 'episode_progress', 'podcast_queue', 'saved_stations',
):
self.assertIn(key, data)
self.assertEqual(len(data['books']), 1)
self.assertEqual(data['books'][0]['meta_ct'], 'meta-ct')
self.assertEqual(len(data['book_progress']), 1)
self.assertEqual(data['book_progress'][0]['book_id'], self.book.id)
self.assertEqual(data['book_progress'][0]['position_anchor'], '3:0.5')
self.assertEqual(len(data['book_highlights']), 1)
self.assertEqual(data['book_highlights'][0]['ct'], 'hl-ct')
# No data_ct/data_iv leaked into the snapshot (book bytes stay lazy-fetched)
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

@ -13,4 +13,10 @@ urlpatterns = [
path('background/upload/', views.upload_background, name='upload_background'),
path('background/delete/', views.delete_background, name='delete_background'),
path('focus-station/', views.save_focus_station, name='save_focus_station'),
path('check-password/', views.check_password, name='check_password'),
path('change-password/', views.change_password, name='change_password'),
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,9 +1,10 @@
import base64
import json
from django.conf import settings
from django.contrib.auth import authenticate, login, get_user_model
from django.contrib import messages
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.forms import UserCreationForm, AuthenticationForm
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, PasswordChangeForm
from django.http import JsonResponse
from django.shortcuts import render, redirect
from django.views.decorators.csrf import csrf_exempt
@ -11,6 +12,9 @@ from django.views.decorators.http import require_http_methods
from radio import lastfm as lastfm_module
from .models import ApiToken, WebDAVSource
from .webdav import WebDAVError, list_directory
User = get_user_model()
@ -71,6 +75,9 @@ def settings_view(request):
context = {
'profile': profile,
'has_lastfm': profile.has_lastfm(),
'password_form': PasswordChangeForm(request.user),
'api_token': getattr(request.user, 'api_token', None),
'webdav_sources': request.user.webdav_sources.all(),
}
return render(request, 'accounts/settings.html', context)
@ -181,6 +188,38 @@ def save_focus_station(request):
return JsonResponse({'ok': True})
@login_required
@csrf_exempt
@require_http_methods(['POST'])
def check_password(request):
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'ok': False}, status=400)
pw = body.get('password', '')
user = authenticate(request, username=request.user.username, password=pw)
return JsonResponse({'ok': user is not None})
@login_required
@require_http_methods(['POST'])
def change_password(request):
form = PasswordChangeForm(request.user, request.POST)
if form.is_valid():
user = form.save()
update_session_auth_hash(request, user)
return redirect('settings')
profile = request.user.profile
return render(request, 'accounts/settings.html', {
'profile': profile,
'has_lastfm': profile.has_lastfm(),
'password_form': form,
'password_form_open': True,
'api_token': getattr(request.user, 'api_token', None),
'webdav_sources': request.user.webdav_sources.all(),
})
@login_required
@require_http_methods(['POST'])
def lastfm_disconnect(request):
@ -189,3 +228,85 @@ def lastfm_disconnect(request):
profile.lastfm_username = ''
profile.save(update_fields=['lastfm_session_key', 'lastfm_username'])
return redirect('settings')
# ---------------------------------------------------------------------------
# API token (for local/native clients — see accounts/middleware.py)
# ---------------------------------------------------------------------------
@login_required
@require_http_methods(['POST'])
def regenerate_api_token(request):
token, _ = ApiToken.objects.get_or_create(user=request.user)
token.regenerate()
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

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0002_ebookbookmarks_ebookhighlights'),
]
operations = [
migrations.AddField(
model_name='ebookprogress',
name='position_anchor',
field=models.CharField(blank=True, default='', max_length=30),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-08-04 16:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0003_ebookprogress_position_anchor'),
]
operations = [
migrations.AddField(
model_name='ebook',
name='is_read',
field=models.BooleanField(default=False),
),
]

View file

@ -9,6 +9,7 @@ class EBook(models.Model):
data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes
data_iv = models.CharField(max_length=32) # hex IV for EPUB data
uploaded_at = models.DateTimeField(auto_now_add=True)
is_read = models.BooleanField(default=False)
class Meta:
ordering = ['uploaded_at']
@ -21,6 +22,7 @@ class EBookProgress(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
scroll_fraction = models.FloatField(default=0.0)
position_anchor = models.CharField(max_length=30, blank=True, default='')
updated_at = models.DateTimeField(auto_now=True)
class Meta:

View file

@ -1,11 +1,19 @@
from django.urls import path
from . import views
from . import views, webdav
urlpatterns = [
path('', views.book_list, name='book_list'),
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>/delete/', views.delete_book, name='delete_book'),
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>/rekey/', views.rekey_book, name='rekey_book'),
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'),

View file

@ -1,6 +1,9 @@
import base64
import json
import re
import xml.etree.ElementTree as ET
import requests
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
@ -15,28 +18,320 @@ def _require_auth(request):
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):
"""Split a position anchor 'blockIndex:innerFraction' into (block, inner).
Returns (-1, 0.0) for empty/invalid anchors (e.g. PDF progress)."""
if not isinstance(anchor, str) or ':' not in anchor:
return (-1, 0.0)
block, _, inner = anchor.partition(':')
try:
return (int(block), float(inner))
except ValueError:
return (-1, 0.0)
def _progress_is_further(new_anchor, new_frac, old_anchor, old_frac):
"""True if the new reading position is at least as far into the book as the
old one. Compares by anchor block index (precise, decoupled from layout);
falls back to scroll_fraction only when an anchor is missing (PDFs)."""
nb, ni = _anchor_parts(new_anchor)
ob, oi = _anchor_parts(old_anchor)
if nb >= 0 and ob >= 0:
return ni >= oi if nb == ob else nb >= ob
return new_frac >= old_frac
@require_http_methods(['GET'])
def book_list(request):
err = _require_auth(request)
if err:
return err
books = list(
request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at')
request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read')
)
for b in books:
b['uploaded_at'] = b['uploaded_at'].isoformat()
# Include saved scroll_fraction for each book
progress_map = {
p.book_id: (p.scroll_fraction, p.updated_at)
p.book_id: (p.scroll_fraction, p.updated_at, p.position_anchor)
for p in EBookProgress.objects.filter(user=request.user)
}
highlighted_ids = set(
EBookHighlights.objects.filter(user=request.user).values_list('book_id', flat=True)
)
for b in books:
prog = progress_map.get(b['id'])
b['scroll_fraction'] = prog[0] if prog else 0.0
b['last_read'] = prog[1].isoformat() if prog else None
b['position_anchor'] = prog[2] if prog else ''
b['has_highlights'] = b['id'] in highlighted_ids
return JsonResponse(books, safe=False)
@csrf_exempt
@require_http_methods(['POST'])
def set_book_read(request, pk):
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)
book.is_read = bool(body.get('is_read', True))
book.save(update_fields=['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
@require_http_methods(['POST'])
def upload_book(request):
@ -65,7 +360,7 @@ def upload_book(request):
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
if raw_size > max_bytes:
return JsonResponse({'error': 'file too large (max 10 MB)'}, status=400)
return JsonResponse({'error': 'file too large (max 50 MB)'}, status=400)
book = EBook.objects.create(
user=request.user,
@ -74,6 +369,7 @@ def upload_book(request):
data_ct=data_ct,
data_iv=data_iv,
)
EBookProgress.objects.create(user=request.user, book=book)
return JsonResponse({'ok': True, 'id': book.id})
@ -91,6 +387,75 @@ def get_book_data(request, pk):
return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
@csrf_exempt
@require_http_methods(['POST'])
def replace_book_data(request, pk):
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)
data_ct = body.get('data_ct', '')
data_iv = body.get('data_iv', '')
meta_ct = body.get('meta_ct', '')
meta_iv = body.get('meta_iv', '')
if not all([data_ct, data_iv]):
return JsonResponse({'error': 'data_ct and data_iv required'}, status=400)
update_fields = ['data_ct', 'data_iv']
book.data_ct = data_ct
book.data_iv = data_iv
if meta_ct and meta_iv:
book.meta_ct = meta_ct
book.meta_iv = meta_iv
update_fields += ['meta_ct', 'meta_iv']
book.save(update_fields=update_fields)
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def rekey_book(request, pk):
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', '')
data_ct = body.get('data_ct', '')
data_iv = body.get('data_iv', '')
if not all([meta_ct, meta_iv, data_ct, data_iv]):
return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400)
book.meta_ct = meta_ct
book.meta_iv = meta_iv
book.data_ct = data_ct
book.data_iv = data_iv
book.save(update_fields=['meta_ct', 'meta_iv', 'data_ct', 'data_iv'])
return JsonResponse({'ok': True})
@csrf_exempt
@require_http_methods(['POST'])
def delete_book(request, pk):
@ -127,12 +492,25 @@ def save_progress(request, pk):
scroll_fraction = float(body.get('scroll_fraction', 0.0))
scroll_fraction = max(0.0, min(1.0, scroll_fraction))
raw_anchor = body.get('position_anchor', '')
position_anchor = ''
if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor):
position_anchor = raw_anchor
force = bool(body.get('force', False))
progress, _ = EBookProgress.objects.get_or_create(
user=request.user,
book=book,
)
# Always advance to the furthest-read position (by anchor block index, so a
# transiently wrong scroll_fraction can't freeze the position) unless the
# client explicitly forces a reset (e.g. "start over" button in the reader).
if force or _progress_is_further(position_anchor, scroll_fraction,
progress.position_anchor, progress.scroll_fraction):
progress.scroll_fraction = scroll_fraction
progress.save(update_fields=['scroll_fraction', 'updated_at'])
progress.position_anchor = position_anchor
progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at'])
return JsonResponse({'ok': True})
@ -172,7 +550,7 @@ def book_highlights(request, pk):
raw_size = len(base64.b64decode(ct))
except Exception:
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
if raw_size > 700 * 1024:
if raw_size > getattr(settings, 'HIGHLIGHTS_MAX_BYTES', 700 * 1024):
return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400)
row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book)
@ -217,7 +595,7 @@ def book_bookmarks(request, pk):
raw_size = len(base64.b64decode(ct))
except Exception:
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
if raw_size > 100 * 1024:
if raw_size > getattr(settings, 'BOOKMARKS_MAX_BYTES', 100 * 1024):
return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400)
row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book)

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

@ -3,3 +3,11 @@ from django.conf import settings
def build_info(request):
return {'BUILD_TIME': getattr(settings, 'BUILD_TIME', '')}
def upload_limits(request):
return {
'EBOOK_MAX_BYTES': getattr(settings, 'EBOOK_MAX_BYTES', 10 * 1024 * 1024),
'BG_MAX_BYTES': getattr(settings, 'BG_MAX_BYTES', 5 * 1024 * 1024),
'PODCAST_INBOX_PAGE_SIZE': getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 200),
}

View file

@ -32,10 +32,10 @@ INSTALLED_APPS = [
'gpodder',
]
EBOOK_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
# Encrypted uploads are base64-encoded (~33% overhead) so allow ~25 MB body
DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024
# Encrypted uploads are base64-encoded (~33% overhead) so allow ~75 MB body
DATA_UPLOAD_MAX_MEMORY_SIZE = 75 * 1024 * 1024
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
@ -44,6 +44,7 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'accounts.middleware.ApiTokenAuthMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
@ -62,6 +63,7 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'diora.context_processors.build_info',
'diora.context_processors.upload_limits',
],
},
},
@ -72,7 +74,7 @@ WSGI_APPLICATION = 'diora.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data' / 'db.sqlite3',
'NAME': BASE_DIR / 'data' / os.environ.get('DIORA_DB_NAME', 'db.sqlite3'),
'OPTIONS': {'timeout': 20},
}
}
@ -101,6 +103,15 @@ MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
BG_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
HIGHLIGHTS_MAX_BYTES = 700 * 1024 # 700 KB
BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB
VOLUME_DEFAULT = 204 # out of 255
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
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
@ -116,4 +127,10 @@ LASTFM_API_SECRET = os.environ.get('LASTFM_API_SECRET', '')
AMAZON_AFFILIATE_TAG = os.environ.get('AMAZON_AFFILIATE_TAG', 'diora-20')
AMAZON_AFFILIATE_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', '')

View file

@ -2,6 +2,9 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
from django.views.static import serve as serve_static
from accounts.sync import sync_snapshot
urlpatterns = [
path('admin/', admin.site.urls),
@ -9,5 +12,12 @@ urlpatterns = [
path('podcasts/', include('podcasts.urls')),
path('books/', include('books.urls')),
path('api/2/', include('gpodder.urls')),
path('api/sync/', sync_snapshot, name='api_sync'),
# Served at the root (not /static/js/sw.js) so its default scope covers
# the whole app — a service worker's scope is limited to its own script's
# directory unless the server sends Service-Worker-Allowed, so registering
# it from under /static/js/ silently restricted it to that subpath and
# navigations to '/', '/books/' etc. were never intercepted while offline.
path('sw.js', serve_static, {'document_root': settings.BASE_DIR / 'static' / 'js', 'path': 'sw.js'}),
path('', include('radio.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

25
e2e/authenticated.spec.js Normal file
View file

@ -0,0 +1,25 @@
// Authenticated smoke tests: reuse the storage state saved in global-setup.js
// for the fixed TEST_USER, so no login step is needed per test.
const { test, expect } = require('@playwright/test');
const path = require('path');
const { TEST_USER } = require('./env');
test.use({ storageState: path.join(__dirname, '.auth', 'user.json') });
test('logged-in home page shows the username and settings link', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.navbar-user')).toHaveText(TEST_USER.username);
await expect(page.locator('a[href="/accounts/settings/"]')).toBeVisible();
});
test('settings page is reachable while authenticated', async ({ page }) => {
await page.goto('/accounts/settings/');
await expect(page).toHaveURL(/\/accounts\/settings\//);
});
test('book list API requires auth and returns JSON for the logged-in user', async ({ request }) => {
const res = await request.get('/books/');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
});

18
e2e/env.js Normal file
View file

@ -0,0 +1,18 @@
// Shared constants between playwright.config.js and e2e/global-setup.js.
const PORT = 8000;
const BASE_URL = `http://127.0.0.1:${PORT}`;
const SERVER_ENV = {
...process.env,
DIORA_DB_NAME: 'e2e_test.sqlite3',
SECRET_KEY: 'e2e-test-secret-key',
DEBUG: 'True',
ALLOWED_HOSTS: 'localhost 127.0.0.1',
};
const TEST_USER = {
username: 'e2e_test_user',
password: 'e2e-test-pw-12345',
};
module.exports = { PORT, BASE_URL, SERVER_ENV, TEST_USER };

60
e2e/global-setup.js Normal file
View file

@ -0,0 +1,60 @@
// Runs once before the e2e suite, after Playwright's webServer is already up
// (see playwright.config.js) but before any test executes.
//
// 1. Resets and migrates a throwaway SQLite DB (data/e2e_test.sqlite3) so
// tests never touch the real dev database.
// 2. Creates a fixed test user.
// 3. Logs that user in through the real login form and saves the resulting
// storage state, so authenticated specs can start already logged in via
// `test.use({ storageState: 'e2e/.auth/user.json' })`.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { chromium } = require('@playwright/test');
const { BASE_URL, SERVER_ENV, TEST_USER } = require('./env');
const ROOT = path.join(__dirname, '..');
const DB_PATH = path.join(ROOT, 'data', SERVER_ENV.DIORA_DB_NAME);
const AUTH_DIR = path.join(__dirname, '.auth');
const AUTH_FILE = path.join(AUTH_DIR, 'user.json');
module.exports = async function globalSetup() {
// Fresh DB every run.
fs.rmSync(DB_PATH, { force: true });
execFileSync('python3', ['manage.py', 'migrate', '--noinput'], {
cwd: ROOT,
env: SERVER_ENV,
stdio: 'inherit',
});
execFileSync(
'python3',
[
'manage.py',
'shell',
'-c',
`
from django.contrib.auth import get_user_model
User = get_user_model()
User.objects.filter(username=${JSON.stringify(TEST_USER.username)}).delete()
User.objects.create_user(${JSON.stringify(TEST_USER.username)}, "", ${JSON.stringify(TEST_USER.password)})
`,
],
{ cwd: ROOT, env: SERVER_ENV, stdio: 'inherit' }
);
fs.mkdirSync(AUTH_DIR, { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ baseURL: BASE_URL });
await page.goto(`${BASE_URL}/accounts/login/`);
await page.fill('[name=username]', TEST_USER.username);
await page.fill('[name=password]', TEST_USER.password);
await Promise.all([
page.waitForURL(BASE_URL + '/'),
page.click('button[type=submit]'),
]);
await page.context().storageState({ path: AUTH_FILE });
await browser.close();
};

37
e2e/smoke.spec.js Normal file
View file

@ -0,0 +1,37 @@
// Unauthenticated smoke tests: no storageState, run as an anonymous visitor.
const { test, expect } = require('@playwright/test');
test('home page loads for an anonymous visitor', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/diora/);
await expect(page.locator('.navbar-brand')).toHaveText('diora');
await expect(page.locator('.navbar-links a[href="/accounts/login/"]')).toBeVisible();
});
test('login page renders the auth form', async ({ page }) => {
await page.goto('/accounts/login/');
await expect(page.locator('[name=username]')).toBeVisible();
await expect(page.locator('[name=password]')).toBeVisible();
});
test('a new user can register and lands on the home page logged in', async ({ page }) => {
const username = `e2e_reg_${Date.now()}`;
await page.goto('/accounts/register/');
await page.fill('[name=username]', username);
await page.fill('[name=password1]', 'a-very-unlikely-pw-98234');
await page.fill('[name=password2]', 'a-very-unlikely-pw-98234');
await Promise.all([
page.waitForURL('/'),
page.click('button[type=submit]'),
]);
await expect(page.locator('.navbar-user')).toHaveText(username);
});
test('login with wrong credentials shows an error and stays on the login page', async ({ page }) => {
await page.goto('/accounts/login/');
await page.fill('[name=username]', 'nobody-such-user');
await page.fill('[name=password]', 'wrong-password');
await page.click('button[type=submit]');
await expect(page).toHaveURL(/\/accounts\/login\//);
await expect(page.locator('.form-errors, .field-errors')).toBeVisible();
});

View file

@ -143,7 +143,7 @@ def subscriptions_by_device(request, username, deviceid):
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
else:
urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True))
return JsonResponse(urls, safe=False)
return JsonResponse({'add': urls, 'remove': [], 'timestamp': _now_ts(), 'update_urls': []})
elif request.method == 'POST':
try:
@ -178,7 +178,7 @@ def subscriptions_all(request, username):
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
else:
urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True))
return JsonResponse(urls, safe=False)
return JsonResponse({'add': urls, 'remove': [], 'timestamp': _now_ts(), 'update_urls': []})
# ---------------------------------------------------------------------------
@ -242,7 +242,7 @@ def episode_actions(request, username):
except Exception:
pass
return JsonResponse({'timestamp': _now_ts()})
return JsonResponse({'timestamp': _now_ts(), 'update_urls': []})
elif request.method == 'GET':
since = request.GET.get('since')

79
package-lock.json generated Normal file
View file

@ -0,0 +1,79 @@
{
"name": "diora-web",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "diora-web",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.62.1"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "diora-web",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"repository": {
"type": "git",
"url": "ssh://git@fg.creamfresh.xyz:2222/mrwnslz/diora-web.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.62.1"
}
}

37
playwright.config.js Normal file
View file

@ -0,0 +1,37 @@
// Playwright config for diora's e2e smoke tests.
//
// Tests run against a real `manage.py runserver` instance backed by its own
// throwaway SQLite database (DIORA_DB_NAME), so they never touch the dev
// database at data/db.sqlite3. See e2e/global-setup.js for how that DB is
// migrated and seeded with a test user.
const path = require('path');
const { defineConfig, devices } = require('@playwright/test');
const { BASE_URL, SERVER_ENV } = require('./e2e/env');
module.exports = defineConfig({
testDir: './e2e',
fullyParallel: false,
workers: 1,
reporter: [['list']],
timeout: 30_000,
use: {
baseURL: BASE_URL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
globalSetup: require.resolve('./e2e/global-setup.js'),
webServer: {
command: 'python3 manage.py runserver 127.0.0.1:8000 --noreload',
url: BASE_URL + '/accounts/login/',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
cwd: path.join(__dirname),
env: SERVER_ENV,
},
});

View file

@ -145,7 +145,7 @@ def podcast_search(request):
try:
url = f'https://itunes.apple.com/search?term={urllib.parse.quote(q)}&media=podcast&limit=20'
resp = requests.get(url, timeout=6)
resp = requests.get(url, timeout=getattr(settings, 'ITUNES_TIMEOUT', 6))
resp.raise_for_status()
raw = resp.json().get('results', [])
except Exception as e:
@ -620,7 +620,7 @@ def inbox(request):
).values_list('episode_id', flat=True)
)
limit = min(int(request.GET.get('limit', 200)), 1000)
limit = min(int(request.GET.get('limit', getattr(settings, 'PODCAST_INBOX_PAGE_SIZE', 200))), 1000)
offset = max(int(request.GET.get('offset', 0)), 0)
episodes = list(

View file

@ -18,4 +18,5 @@ urlpatterns = [
path('radio/notes/<int:pk>/', views.save_station_notes, name='save_station_notes'),
path('radio/focus/record/', views.record_focus_session, name='record_focus_session'),
path('radio/focus/stats/', views.focus_stats, name='focus_stats'),
path('radio/stream-player/', views.stream_player, name='stream_player'),
]

View file

@ -1,4 +1,6 @@
import json
import socket
import ssl as ssl_module
import time
import urllib.parse
from datetime import datetime
@ -204,7 +206,7 @@ def affiliate_links(request):
f"https://itunes.apple.com/search"
f"?term={urllib.parse.quote(track)}&media=music&limit=1"
)
resp = requests.get(itunes_url, timeout=5)
resp = requests.get(itunes_url, timeout=getattr(settings, 'ITUNES_TIMEOUT', 6))
resp.raise_for_status()
results = resp.json().get('results', [])
if results:
@ -573,3 +575,19 @@ def import_m3u(request):
skipped += 1
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
# ---------------------------------------------------------------------------
# Minimal HTTP stream player (standalone tab for mixed-content streams)
# ---------------------------------------------------------------------------
def stream_player(request):
url = request.GET.get('url', '').strip()
name = request.GET.get('name', '').strip()
_vol_default = getattr(settings, 'VOLUME_DEFAULT', 204)
vol = request.GET.get('vol', str(_vol_default)).strip()
try:
vol = max(0, min(255, int(vol)))
except ValueError:
vol = _vol_default
return render(request, 'radio/stream_player.html', {'stream_url': url, 'stream_name': name, 'stream_vol': vol})

View file

@ -8,6 +8,17 @@
padding: 0;
}
html, body {
height: 100%;
overflow: hidden;
scrollbar-color: #333 #000;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #000; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
:root {
--bg: #000;
--bg-card: transparent;
@ -66,7 +77,8 @@ html, body {
.btn-lastfm,
.btn-danger,
.btn-primary,
.navbar-brand {
.navbar-brand,
.reader-toast {
text-shadow: none;
}
@ -96,8 +108,16 @@ a:hover {
justify-content: space-between;
height: var(--nav-h);
padding: 0 1.5rem;
background: transparent;
background: var(--bg);
border-bottom: 1px solid var(--border);
/* Window Controls Overlay: extend navbar into the titlebar area */
padding-left: max(1.5rem, env(titlebar-area-x, 1.5rem));
padding-top: env(titlebar-area-y, 0);
height: calc(var(--nav-h) + env(titlebar-area-y, 0px));
-webkit-app-region: drag;
}
.navbar a, .navbar button, .navbar input, .navbar form {
-webkit-app-region: no-drag;
}
.navbar-brand {
@ -130,7 +150,9 @@ a:hover {
.main-content {
max-width: 1100px;
margin: 0 auto;
padding: 1rem 1.5rem calc(var(--bar-h) + 2rem);
padding: calc(var(--nav-h) + 3rem) 1.5rem calc(var(--bar-h) + 2rem);
height: 100%;
overflow-y: auto;
}
/* =========================================================
@ -162,7 +184,7 @@ a:hover {
left: 0;
right: 0;
height: var(--bar-h);
background: transparent;
background: var(--bg);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
@ -294,6 +316,18 @@ a:hover {
gap: 0;
}
/* Haupttab-Leiste: immer sichtbar unterhalb der Navbar */
#tabs {
position: fixed;
top: var(--nav-h);
left: 0;
right: 0;
z-index: 150;
background: var(--bg);
margin-bottom: 0;
padding: 0 0.5rem;
}
.tab-btn {
background: none;
border: none;
@ -676,6 +710,161 @@ a:hover {
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
========================================================= */
@ -715,7 +904,10 @@ a:hover {
@media (max-width: 600px) {
.main-content {
padding: 0.75rem 0.75rem calc(var(--bar-h) + 1.5rem);
padding: calc(var(--nav-h) + 3rem) 0.75rem calc(var(--bar-h) + 1.5rem);
}
#tabs {
padding: 0 0.25rem;
}
.now-playing-bar {
@ -734,8 +926,10 @@ a:hover {
justify-content: space-between;
}
.volume-slider {
width: 60px;
.volume-label,
.volume-slider,
.volume-num {
display: none;
}
.affiliate-section {
@ -1316,6 +1510,39 @@ body.dnd-mode .timer-display {
line-height: 1.6;
}
/* ===== MODAL DIALOG ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 500;
}
.modal-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--surface, #111);
border: 1px solid var(--border, #333);
border-radius: var(--radius);
padding: 20px;
width: 360px;
max-width: calc(100vw - 32px);
z-index: 501;
}
.modal-message {
margin: 0 0 16px;
white-space: pre-wrap;
line-height: 1.5;
}
.modal-input { width: 100%; margin-bottom: 16px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* Style links and basic HTML inside shownotes */
.sidebar-body a { color: var(--accent, #e63946); }
.sidebar-body p { margin: 0 0 10px; }
@ -1409,6 +1636,8 @@ body.dnd-mode .timer-display {
padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
}
.reader-marker-btn-mobile { display: none; }
@media (max-width: 600px) {
.sidebar { width: 100vw; }
.podcast-seek-bar { padding: 0 6px; }
@ -1416,6 +1645,19 @@ body.dnd-mode .timer-display {
.podcast-thumb-lg { width: 60px; height: 60px; }
.podcast-feed-actions { flex-direction: column; }
.episode-actions { flex-direction: column; }
/* Reader auf Mobile: Header kompakter, Fortschrittsfeld versteckt */
.reader-header { padding: 6px 8px; gap: 4px; }
.reader-title { font-size: 13px; }
.reader-header-actions { gap: 4px; }
.reader-progress-wrap { display: none; }
.reader-content { padding: 16px 10px; }
/* On a phone, opening the 150px margin just to reach the marker-mode toggle
eats too much of an already-narrow reading column surface it directly
in the main header instead (desktop keeps it only in the margin header,
grouped with "Notes", per earlier feedback against back-and-forth). */
.reader-marker-btn-mobile { display: inline-block; }
}
/* =========================================================
@ -1450,6 +1692,12 @@ body.dnd-mode .timer-display {
padding: 24px 0 16px;
}
.offline-notice {
font-size: 13px;
color: var(--accent);
padding: 4px 0 8px;
}
.book-key-bar {
display: flex;
align-items: center;
@ -1490,11 +1738,138 @@ body.dnd-mode .timer-display {
.book-progress {
font-size: 12px;
}
.book-item-meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.book-item-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.book-read-badge {
font-size: 12px;
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 {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted, #888);
margin-top: 10px;
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-page-wrapper {
@ -1520,6 +1895,11 @@ body.dnd-mode .timer-display {
z-index: 200;
display: flex;
flex-direction: column;
/* The global white text-outline (for readability over a custom background
image elsewhere in the app) is never needed here the reader always
sits on its own opaque background and it muddies highlighted text
badly when combined with a colored highlight background. */
text-shadow: none;
}
.reader-header {
display: flex;
@ -1552,6 +1932,8 @@ body.dnd-mode .timer-display {
.reader-content {
flex: 1;
overflow-y: scroll;
overflow-x: auto;
position: relative;
padding: 24px 16px;
line-height: 1.8;
font-family: Georgia, 'Times New Roman', serif;
@ -1572,23 +1954,13 @@ body.dnd-mode .timer-display {
}
/* --- Focus station sidebar --- */
.focus-preset-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
margin: 10px 0;
}
.focus-preset-list li.focus-preset-active button {
border-color: var(--accent);
color: var(--accent);
}
.focus-custom-input {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
/* --- Radio sidebar --- */
.rsb-nowplaying { margin-bottom: 12px; }
.rsb-station-name { font-weight: 600; }
.rsb-track { font-size: 0.85rem; margin-top: 2px; }
.rsb-controls { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.rsb-vol { display: flex; align-items: center; gap: 6px; font-size: 0.85rem; color: var(--muted, #888); }
.rsb-station-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
/* --- Table of contents sidebar --- */
.toc-list {
@ -1653,22 +2025,172 @@ body.dnd-mode .timer-display {
/* PDF invert */
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); }
/* ---- Immersive reader mode ---- */
.navbar { transition: transform 0.35s ease; }
.now-playing-bar{ transition: transform 0.35s ease; }
.reader-overlay { transition: top 0.35s ease, bottom 0.35s ease; }
.reader-header { overflow: hidden; max-height: 60px;
transition: max-height 0.35s ease, padding-top 0.35s ease, padding-bottom 0.35s ease; }
body.reader-immersive .navbar { transform: translateY(-100%); }
body.reader-immersive .now-playing-bar { transform: translateY(100%); }
body.reader-immersive .reader-overlay { top: 0; bottom: 0; }
body.reader-immersive .reader-header { max-height: 0; padding-top: 0; padding-bottom: 0; }
body.reader-immersive.reader-show-top .navbar { transform: none; }
body.reader-immersive.reader-show-top .reader-overlay { top: var(--nav-h); }
body.reader-immersive.reader-show-top .reader-header { max-height: 60px; padding-top: 8px; padding-bottom: 8px; }
body.reader-immersive.reader-show-bottom .now-playing-bar { transform: none; }
body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h); }
/* PDF paginated */
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
.pdf-paginated .pdf-page-wrapper { margin:0; }
/* PDF loading overlay */
.pdf-loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 20, 20, 0.85);
z-index: 200;
}
.pdf-loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #333;
border-top-color: var(--accent, #e63946);
border-radius: 50%;
animation: pdf-spin 0.7s linear infinite;
}
@keyframes pdf-spin { to { transform: rotate(360deg); } }
/* PDF two-page spread */
.pdf-spread-wrapper { display:flex; flex-direction:row; gap:8px; justify-content:center; margin-bottom:1rem; }
.pdf-spread-wrapper .pdf-page-wrapper { margin:0; }
.pdf-spread-cover { margin-bottom:1rem; }
/* Disable text selection during pinch */
#reader-content.pinch-active { user-select:none; -webkit-user-select:none; }
/* Highlight popover */
.highlight-popover { position:fixed; z-index:500; display:flex; gap:6px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.5); }
.hl-color-btn { width:24px; height:24px; border-radius:50%; border:2px solid transparent; cursor:pointer; font-weight:700; font-size:12px; color:#000; line-height:1; }
.hl-color-btn:hover { border-color:#fff; }
.hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; }
/* Footnote popover */
.footnote-popover { position:fixed; z-index:500; max-width:min(400px,90vw); max-height:220px; overflow-y:auto; background:#1a1a1a; color:#e8e8e8; border:1px solid #444; border-radius:var(--radius); padding:10px 28px 10px 12px; box-shadow:0 4px 20px rgba(0,0,0,.6); font-size:0.88em; line-height:1.55; pointer-events:auto; }
.footnote-popover.reader-theme-sepia { background:#ede0c4; color:#3b2a1a; border-color:#c8b89a; }
.footnote-popover.reader-theme-bright { background:#f0f0f0; color:#111; border-color:#bbb; }
.footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; }
/* Highlight marks */
.epub-highlight { border-radius:2px; cursor:pointer; }
.epub-highlight[data-color="yellow"] { background:rgba(241,196,15,.4); }
.epub-highlight[data-color="green"] { background:rgba(46,204,113,.35); }
.epub-highlight[data-color="blue"] { background:rgba(52,152,219,.35); }
.epub-highlight[data-color="red"] { background:rgba(230,57,70,.35); }
.reader-no-bold * { font-weight: normal !important; }
.reader-content img { max-width: 100%; height: auto; display: block; }
/* color:inherit overrides the browser's UA-default `mark { color: black }`
without it, highlighted text abruptly flips to black regardless of the
reader theme's own text color (jarring on the default dark theme, whose
body text is white). Inheriting keeps highlighted text the same color as
the text around it in every theme; the background tones below are chosen
dark/saturated enough to stay readable specifically against white text. */
.epub-highlight { border-radius:2px; cursor:pointer; color:inherit; }
.epub-highlight[data-color="yellow"] { background:rgba(184,134,11,.55); }
.epub-highlight[data-color="green"] { background:rgba(30,140,90,.55); }
.epub-highlight[data-color="blue"] { background:rgba(41,110,180,.55); }
.epub-highlight[data-color="red"] { background:rgba(190,50,60,.55); }
/* Margin ("page margin") panel left of the reader content. Highlights without
a note show as a small color dot; anything with note text (freeform notes or
a highlight+note) shows as a readable little "post-it" card, positioned next
to the text height it belongs to. Click empty space to place a new freeform
note anchored to that line. Freeform notes have NO in-text mark the anchor
is purely positional, only ever visible here in the margin. */
.reader-body-row { display:flex; flex:1; min-height:0; }
.reader-margin {
width:0; flex-shrink:0; overflow:hidden;
display:flex; flex-direction:column;
border-right:1px solid var(--border); background:var(--bg);
transition:width 0.25s ease;
}
.reader-margin.open { width:220px; }
.reader-margin-header {
display:none; align-items:center; justify-content:space-between;
padding:6px 8px; border-bottom:1px solid var(--border); white-space:nowrap; flex-shrink:0;
}
.reader-margin.open .reader-margin-header { display:flex; }
.reader-margin-title { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted,#888); }
.reader-margin-header-actions { display:flex; align-items:center; gap:6px; }
.reader-margin-markers { position:relative; flex:1; overflow-y:auto; overflow-x:hidden; cursor:crosshair; }
.margin-note { position:absolute; left:8px; right:8px; cursor:pointer; }
.margin-note-dot { left:8px; right:auto; width:12px; height:12px; border-radius:50%; }
.margin-note-dot[data-color="yellow"] { background:rgba(241,196,15,.85); }
.margin-note-dot[data-color="green"] { background:rgba(46,204,113,.85); }
.margin-note-dot[data-color="blue"] { background:rgba(52,152,219,.85); }
.margin-note-dot[data-color="red"] { background:rgba(230,57,70,.85); }
.margin-note-text {
font-size:11px; line-height:1.4; padding:5px 7px; border-radius:3px;
background:var(--bg-card,#1a1a1a); border-left:3px solid var(--muted,#888);
box-shadow:1px 2px 5px rgba(0,0,0,.3);
white-space:pre-wrap; word-break:break-word;
}
.margin-note-text[data-color="yellow"] { border-left-color:#f1c40f; }
.margin-note-text[data-color="green"] { border-left-color:#2ecc71; }
.margin-note-text[data-color="blue"] { border-left-color:#3498db; }
.margin-note-text[data-color="red"] { border-left-color:#e63946; }
/* Freeform notes get a paper-like tint, like a post-it note pinned to the text */
.margin-note-text.margin-note-freeform {
border-left-color:#e6c229; background:rgba(241,196,15,.13);
}
/* Inline note editor appears right where the note lives in the margin, so
setting/editing a note never means jumping to a separate panel */
.margin-note-editor { position:absolute; left:8px; right:8px; z-index:1; }
.margin-note-textarea {
width:100%; min-height:52px; font-size:11px; line-height:1.4; padding:5px 7px;
border-radius:3px; border:1px solid var(--accent,#e63946);
background:var(--bg-card,#1a1a1a); color:var(--fg,#eee);
box-shadow:1px 2px 5px rgba(0,0,0,.4); resize:vertical;
}
/* Note bottom sheet the mobile alternative to the inline margin editor.
Typing a note into a 150px-wide margin column is unpleasant on a phone
keyboard, and getting there first requires opening that column at all, so
on narrow viewports note editing uses this full-width sheet instead (see
_isMobileReader() / _openNoteEditor() in app.js). Existing notes staying
less discoverable on mobile is an accepted tradeoff reviewing them in
depth is expected to happen on desktop; setting them fast is what matters
here. */
.note-bottom-sheet { position:fixed; inset:0; z-index:650; }
.note-bottom-sheet-backdrop {
position:absolute; inset:0; background:rgba(0,0,0,.5);
opacity:0; transition:opacity .2s ease; pointer-events:none;
}
.note-bottom-sheet.open .note-bottom-sheet-backdrop { opacity:1; pointer-events:auto; }
.note-bottom-sheet-panel {
position:absolute; left:0; right:0; bottom:0;
background:var(--surface,#111); border-top:1px solid var(--border,#333);
border-radius:12px 12px 0 0; padding:12px 14px calc(14px + env(safe-area-inset-bottom));
transform:translateY(100%); transition:transform .25s ease;
display:flex; flex-direction:column; gap:10px;
}
.note-bottom-sheet.open .note-bottom-sheet-panel { transform:translateY(0); }
.note-bottom-sheet-textarea {
width:100%; min-height:110px; font-size:15px; line-height:1.4; padding:8px 10px;
border-radius:var(--radius); border:1px solid var(--border,#333);
background:var(--bg-card,#1a1a1a); color:var(--fg,#eee); resize:vertical;
}
.note-bottom-sheet-actions { display:flex; justify-content:flex-end; gap:8px; }
@media (max-width: 600px) {
.reader-margin.open { width:150px; }
.reader-margin-title { display:none; }
}
/* Search matches */
mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border-radius:2px; }
@ -1682,6 +2204,11 @@ mark.reader-search-match.active { background:rgba(230,57,70,.7); }
.reader-toast { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); background:var(--fg); color:var(--bg); padding:6px 14px; border-radius:var(--radius); font-size:13px; z-index:600; animation:toast-fade 2s ease forwards; pointer-events:none; }
@keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} }
.reader-toast-action { display:flex; align-items:center; gap:10px; padding:8px 8px 8px 14px; animation:toast-slide-up 0.2s ease forwards; pointer-events:auto; }
.reader-toast-btn { background:var(--accent,#e63946); color:#fff; border:none; border-radius:calc(var(--radius) - 2px); padding:5px 12px; font-family:var(--font); font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; }
.reader-toast-btn:hover { filter:brightness(1.1); }
@keyframes toast-slide-up { 0%{opacity:0; transform:translate(-50%, 8px);} 100%{opacity:1; transform:translate(-50%, 0);} }
.build-time {
position: fixed;
bottom: 4px;

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use.
*/
const CACHE = 'diora-v7';
const CACHE = 'diora-v41';
const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [
'/static/css/app.css',
@ -31,6 +31,13 @@ self.addEventListener('activate', function (event) {
);
}).then(function () {
return self.clients.claim();
}).then(function () {
// Tell all open tabs to reload so they get the latest cached assets
return self.clients.matchAll({type: 'window'}).then(function (clients) {
clients.forEach(function (client) {
client.postMessage({type: 'SW_ACTIVATED'});
});
});
})
);
});
@ -71,7 +78,7 @@ self.addEventListener('fetch', function (event) {
return;
}
// Cache-first only for pre-defined shell assets; everything else hits the network
// Cache-first for pre-defined shell assets
const isShell = SHELL.some(function (s) { return url.pathname === s; });
if (isShell) {
event.respondWith(
@ -79,5 +86,22 @@ self.addEventListener('fetch', function (event) {
return cached || fetch(event.request);
})
);
return;
}
// Navigation requests (the HTML page itself): network-first, fall back to
// last cached version so the app opens offline after being visited once.
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).then(function (response) {
if (response.ok) {
var clone = response.clone();
caches.open(CACHE).then(function (cache) { cache.put(event.request, clone); });
}
return response;
}).catch(function () {
return caches.match(event.request);
})
);
}
});

View file

@ -4,6 +4,7 @@
"description": "Internet radio player",
"start_url": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#000000",
"theme_color": "#000000",
"orientation": "any",
@ -12,13 +13,25 @@
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View file

@ -72,11 +72,128 @@
<section class="settings-section">
<h2>Account</h2>
<p>Logged in as <strong>{{ request.user.username }}</strong></p>
<form method="post" action="{% url 'logout' %}" class="inline-form">
<details {% if password_form_open %}open{% endif %} style="margin-top:1rem;">
<summary class="btn" style="display:inline-block;cursor:pointer;">Change password</summary>
<form id="pw-change-form" method="post" action="{% url 'change_password' %}" style="margin-top:1rem; display:flex; flex-direction:column; gap:0.6rem; max-width:320px;">
{% csrf_token %}
{% for field in password_form %}
<div>
<label style="display:block; font-size:0.85rem; margin-bottom:2px;">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="message message-error" style="margin-top:4px; padding:4px 8px; font-size:0.8rem;">{{ field.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
{% if password_form.non_field_errors %}
<div class="message message-error" style="padding:4px 8px; font-size:0.8rem;">{{ password_form.non_field_errors|join:", " }}</div>
{% endif %}
<div id="pw-change-status" style="font-size:0.85rem; color:#888;"></div>
<div><button type="submit" class="btn">Save</button></div>
</form>
</details>
<form method="post" action="{% url 'logout' %}" class="inline-form" style="margin-top:1rem;">
{% csrf_token %}
<button type="submit" class="btn">Logout</button>
</form>
</section>
<!-- API token section -->
<section class="settings-section">
<h2>API-Zugriff für lokale Apps</h2>
<p class="lastfm-description">
Mit einem persönlichen Token können eigene Apps auf deine diora-Daten zugreifen (Bücher,
Lesefortschritt, Notizen, Podcasts, Radio) — ohne dein Passwort zu teilen. Token als
<code>Authorization: Bearer &lt;token&gt;</code>-Header mitschicken, z. B. gegen
<code>GET /api/sync/</code> für einen kompletten Datenabzug.
</p>
{% if api_token %}
<p style="margin-top:0.75rem;">
<code style="user-select:all; word-break:break-all; background:#1a1a1a; padding:4px 8px; border-radius:4px; display:inline-block;">{{ api_token.token }}</code>
</p>
<form method="post" action="{% url 'regenerate_api_token' %}" class="inline-form" style="margin-top:0.75rem;"
onsubmit="return confirm('Neues Token generieren? Bereits verbundene Apps können sich dann nicht mehr anmelden.');">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Token neu generieren</button>
</form>
{% else %}
<form method="post" action="{% url 'regenerate_api_token' %}" class="inline-form" style="margin-top:0.75rem;">
{% csrf_token %}
<button type="submit" class="btn">Token generieren</button>
</form>
{% endif %}
</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>
{% endblock %}
@ -121,6 +238,11 @@ async function _getOrCreateEncKey() {
return key;
}
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
const ok = confirm('Nach dem Ändern des Passworts können alle hochgeladenen Bücher nicht mehr geöffnet werden und müssen erneut hochgeladen werden. Fortfahren?');
if (!ok) e.preventDefault();
});
async function uploadBackground(input) {
const file = input.files[0];
if (!file) return;

View file

@ -13,6 +13,7 @@
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="stylesheet" href="/static/css/app.css">
<title>{% block title %}diora{% endblock %}</title>
<script>const DIORA_CONFIG = { ebookMaxBytes: {{ EBOOK_MAX_BYTES }}, bgMaxBytes: {{ BG_MAX_BYTES }}, podcastInboxPageSize: {{ PODCAST_INBOX_PAGE_SIZE }} };</script>
{% if encrypted_bg_json %}
<script>const ENCRYPTED_BG = {{ encrypted_bg_json|safe }};</script>
{% endif %}

View file

@ -18,14 +18,14 @@
</label>
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</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="openFocusStationSidebar()" title="Focus station">📻</button>
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio"></button>
</div>
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">&thinsp;15</button>
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">«&thinsp;15</button>
<span class="seek-time" id="seek-current">0:00</span>
<input type="range" id="seek-slider" class="seek-slider" min="0" max="100" value="0">
<span class="seek-time" id="seek-duration">0:00</span>
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30&thinsp;</button>
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30&thinsp;»</button>
<div class="speed-btns" id="speed-btns">
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</button>
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button>
@ -43,7 +43,7 @@
<button class="btn-icon" id="timer-toggle-btn" onclick="toggleTimer()" title="Start/pause timer"></button>
<button class="btn-icon" id="timer-reset-btn" onclick="resetTimer()" title="Reset timer"></button>
<span class="focus-today" id="focus-today-widget" style="display:none;"></span>
<button class="btn-icon dnd-only" id="dnd-light-btn" onclick="toggleDNDLight()" title="Toggle black background">💡</button>
<button class="btn-icon dnd-only" id="dnd-light-btn" onclick="toggleDNDLight()" title="Toggle black background"></button>
</div>
</section>
@ -131,7 +131,7 @@
<table class="data-table" id="saved-table">
<thead>
<tr>
<th>&#9733;</th>
<th title="Favorite">&#9733;</th>
<th>Name</th>
<th>Bitrate</th>
<th>Country</th>
@ -319,7 +319,21 @@
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
<span id="book-upload-status" class="muted"></span>
</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>
<label class="book-list-filter">
<input type="checkbox" id="book-show-read-toggle" onchange="_onBookShowReadToggle(this.checked)">
Gelesene Bücher anzeigen
</label>
<div id="book-list" class="book-list"></div>
{% else %}
<p class="auth-prompt">
@ -335,19 +349,35 @@
<span id="reader-title" class="reader-title"></span>
<div class="reader-header-actions">
<span class="reader-progress-wrap">
<input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;">
<input type="text" inputmode="decimal" id="reader-progress-input" class="volume-num" value="0" style="display:none;">
<span id="reader-progress-suffix" class="muted"></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-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-margin-btn" onclick="toggleAnnotationsMargin()" title="Notes &amp; highlights"></button>
<button class="btn-icon marker-mode-btn reader-marker-btn-mobile" id="reader-marker-btn-mobile" onclick="toggleMarkerMode()" title="Text markieren"></button>
<button class="btn-icon" id="reader-reset-pos-btn" onclick="saveReaderProgress(true)" title="Diese Position als Lesefortschritt setzen (überschreibt gespeicherten Fortschritt)"></button>
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)"></button>
</div>
</div>
<div id="reader-body-row" class="reader-body-row">
<aside id="reader-margin" class="reader-margin">
<div class="reader-margin-header">
<span class="reader-margin-title">Notes</span>
<span class="reader-margin-header-actions">
<button class="btn-icon marker-mode-btn" id="reader-marker-btn" onclick="toggleMarkerMode()" title="Mark text"></button>
<button class="btn-icon" id="reader-margin-list-btn" onclick="openAnnotationsSidebar()" title="List all highlights &amp; notes, jump to any of them"></button>
<button class="btn-icon" id="reader-margin-export-btn" onclick="exportAnnotations()" title="Export as text file"></button>
</span>
</div>
<div id="reader-margin-markers" class="reader-margin-markers"></div>
</aside>
<div id="reader-content" class="reader-content"></div>
</div>
</div>
<!-- ===== SIDEBAR ===== -->
<div id="sidebar-overlay" class="sidebar-overlay" onclick="closeSidebar()" style="display:none;"></div>
@ -359,6 +389,17 @@
<div id="sidebar-body" class="sidebar-body"></div>
</aside>
<!-- ===== MODAL DIALOG (replaces native alert/confirm/prompt) ===== -->
<div id="modal-overlay" class="modal-overlay" style="display:none;"></div>
<div id="modal-dialog" class="modal-dialog" style="display:none;" role="alertdialog" aria-modal="true">
<p id="modal-message" class="modal-message"></p>
<input type="text" id="modal-input" class="search-input modal-input" style="display:none;">
<div class="modal-actions">
<button type="button" id="modal-cancel-btn" class="btn" style="display:none;">Cancel</button>
<button type="button" id="modal-ok-btn" class="btn btn-primary">OK</button>
</div>
</div>
{% endblock %}
{% block extra_js %}

View file

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ stream_name|default:"Radio" }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0d0d0d;
color: #e0e0e0;
min-height: 100svh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 24px;
}
#station-name {
font-size: 1.3rem;
font-weight: 700;
text-align: center;
}
#track-name {
font-size: 0.9rem;
color: #888;
text-align: center;
min-height: 1.2em;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
}
#play-btn {
background: #e63946;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-size: 1rem;
cursor: pointer;
min-width: 90px;
}
#play-btn:hover { background: #c1121f; }
.vol-wrap {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: #888;
}
#vol-slider { width: 90px; accent-color: #e63946; }
#vol-num {
width: 42px;
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
border-radius: 4px;
padding: 2px 4px;
font-size: 0.85rem;
text-align: center;
}
#back-btn {
background: none;
border: none;
color: #555;
font-size: 0.8rem;
cursor: pointer;
text-decoration: underline;
margin-top: 8px;
}
#back-btn:hover { color: #aaa; }
</style>
</head>
<body>
<div id="station-name">{{ stream_name|default:"Radio" }}</div>
<div id="track-name"></div>
<div class="controls">
<button id="play-btn">&#9654; Play</button>
<div class="vol-wrap">
<span>vol</span>
<input type="range" id="vol-slider" min="0" max="255" value="{{ stream_vol }}">
<input type="number" id="vol-num" min="0" max="255" value="{{ stream_vol }}">
</div>
</div>
<button id="back-btn" onclick="window.close()">&#8592; close tab</button>
<script>
const audio = new Audio();
let playing = false;
let sse = null;
const streamUrl = '{{ stream_url|escapejs }}';
const stationName = '{{ stream_name|escapejs }}';
// Volume
function setVol(v) {
v = Math.max(0, Math.min(255, Math.round(v)));
audio.volume = v / 255;
document.getElementById('vol-slider').value = v;
document.getElementById('vol-num').value = v;
try { localStorage.setItem('diora_volume', v); } catch (_) {}
}
const slider = document.getElementById('vol-slider');
const numIn = document.getElementById('vol-num');
slider.addEventListener('input', () => setVol(parseInt(slider.value, 10)));
numIn.addEventListener('change', () => setVol(parseInt(numIn.value, 10)));
// Play / Stop
const playBtn = document.getElementById('play-btn');
function startPlay() {
audio.src = streamUrl;
setVol(parseInt(slider.value, 10));
audio.play().then(() => {
playing = true;
playBtn.innerHTML = '&#9646;&#9646; Stop';
if (sse) sse.close();
sse = new EventSource('/radio/sse/?url=' + encodeURIComponent(streamUrl));
sse.onmessage = e => {
try {
const data = JSON.parse(e.data);
if (data.track) {
document.getElementById('track-name').textContent = data.track;
document.title = data.track + ' — ' + stationName;
}
} catch (_) {}
};
}).catch(() => {
// Autoplay blocked — reset state, prompt user to click
playing = false;
audio.src = '';
playBtn.innerHTML = '&#9654; Play';
document.getElementById('track-name').textContent = 'Click Play to start';
});
}
function stopPlay() {
audio.pause();
audio.src = '';
playing = false;
playBtn.innerHTML = '&#9654; Play';
document.getElementById('track-name').textContent = '';
document.title = stationName;
if (sse) { sse.close(); sse = null; }
}
playBtn.addEventListener('click', () => {
if (playing) stopPlay(); else startPlay();
});
document.getElementById('track-name').textContent = 'Click Play to start';
</script>
</body>
</html>