Compare commits
85 commits
7392bbcdcc
...
b205625e21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b205625e21 | ||
|
|
0de6c186fb | ||
|
|
635fd7ef1c | ||
|
|
e6b3ee620e | ||
|
|
3e301b6f61 | ||
|
|
7d99e58286 | ||
|
|
30a6d29ca8 | ||
|
|
bb143a249b | ||
|
|
5053d0036a | ||
|
|
e70bb881cc | ||
|
|
d3c435dd59 | ||
|
|
76068f6ed8 | ||
|
|
42bc090a28 | ||
|
|
b805d62b11 | ||
|
|
f9573d3d62 | ||
|
|
9648730be6 | ||
|
|
7bfce034ba | ||
|
|
3f40a4078a | ||
|
|
a2f4be8e1e | ||
|
|
bc180daab0 | ||
|
|
090aec9c60 | ||
|
|
17e205b6f0 | ||
|
|
daffd0002b | ||
|
|
29f6cc4413 | ||
|
|
28756ffdc2 | ||
|
|
b097eea600 | ||
|
|
5d69edc57e | ||
|
|
d7a27b188d | ||
|
|
aa562f8b4d | ||
|
|
a96591c63f | ||
|
|
5ba1a7bdad | ||
|
|
3d25519367 | ||
|
|
5504e5c627 | ||
|
|
bfa0439aa1 | ||
|
|
159aa6f340 | ||
|
|
fe91000e7c | ||
|
|
4b47f6e67a | ||
|
|
817323ad19 | ||
|
|
1426c69a73 | ||
|
|
e3a9c61c05 | ||
|
|
c064d0d4e1 | ||
|
|
d64f159044 | ||
|
|
2d488fd542 | ||
|
|
9241d6170b | ||
|
|
1cf3f730ea | ||
|
|
20a1b9a889 | ||
|
|
89cb70392a | ||
|
|
b6619f6465 | ||
|
|
d607388bad | ||
|
|
bbb982d0b1 | ||
|
|
846b299713 | ||
|
|
d839a8f8a3 | ||
|
|
1fdf5f9b30 | ||
|
|
8a3aedb0e7 | ||
|
|
def879de2d | ||
|
|
f5c141626f | ||
|
|
e5dc58d84f | ||
|
|
554ca93e30 | ||
|
|
6b419c6fe0 | ||
|
|
916e8a568b | ||
|
|
e9c5b8058b | ||
|
|
0a6ba6feac | ||
|
|
9c0a046c57 | ||
|
|
4274f49971 | ||
|
|
2ba613fdd8 | ||
|
|
aff4f5aef2 | ||
|
|
1bda59e3fc | ||
|
|
68bb7b5920 | ||
|
|
dbe3b46f3e | ||
|
|
2448586050 | ||
|
|
1af07c7952 | ||
|
|
0c6846e71f | ||
|
|
bef8fbc8d8 | ||
|
|
bdb6857c73 | ||
|
|
44d51d3a7f | ||
|
|
a205eafd79 | ||
|
|
2859464b14 | ||
|
|
da300b54c7 | ||
|
|
f040a45325 | ||
|
|
9e08079dec | ||
|
|
ee8cfd8314 | ||
|
|
0037fd8db4 | ||
|
|
85776390f6 | ||
|
|
83304c197d | ||
|
|
38451514c2 |
39 changed files with 5656 additions and 431 deletions
|
|
@ -3,3 +3,8 @@ DEBUG=True
|
||||||
AMAZON_AFFILIATE_TAG=diora-20
|
AMAZON_AFFILIATE_TAG=diora-20
|
||||||
LASTFM_API_KEY=
|
LASTFM_API_KEY=
|
||||||
LASTFM_API_SECRET=
|
LASTFM_API_SECRET=
|
||||||
|
|
||||||
|
# Cloud import (WebDAV/Nextcloud): allow connections to LAN/loopback addresses.
|
||||||
|
# Leave False on any instance with open registration — it is what stops a user
|
||||||
|
# from probing the internal network through the import proxy.
|
||||||
|
WEBDAV_ALLOW_PRIVATE_HOSTS=False
|
||||||
|
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -33,3 +33,14 @@ CLAUDE.md
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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
25
accounts/middleware.py
Normal 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)
|
||||||
26
accounts/migrations/0004_apitoken.py
Normal file
26
accounts/migrations/0004_apitoken.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
33
accounts/migrations/0005_webdavsource.py
Normal file
33
accounts/migrations/0005_webdavsource.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import secrets
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models.signals import post_save
|
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):
|
def save_user_profile(sender, instance, **kwargs):
|
||||||
if hasattr(instance, 'profile'):
|
if hasattr(instance, 'profile'):
|
||||||
instance.profile.save()
|
instance.profile.save()
|
||||||
|
|
||||||
|
|
||||||
|
class WebDAVSource(models.Model):
|
||||||
|
"""A user-supplied WebDAV endpoint that ebooks can be imported from.
|
||||||
|
|
||||||
|
Deliberately generic rather than Nextcloud-specific — any WebDAV server
|
||||||
|
(Nextcloud, ownCloud, Synology, rclone serve, …) works, Nextcloud just gets
|
||||||
|
a URL-shorthand in `normalized_base_url`.
|
||||||
|
|
||||||
|
The password is stored in the clear because the server has to replay it on
|
||||||
|
every PROPFIND/GET (same trade-off as `lastfm_session_key` above). The UI
|
||||||
|
therefore tells users to create a revocable *app password* rather than
|
||||||
|
handing over their account password.
|
||||||
|
"""
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='webdav_sources')
|
||||||
|
label = models.CharField(max_length=100)
|
||||||
|
base_url = models.URLField(max_length=500)
|
||||||
|
username = models.CharField(max_length=200, blank=True)
|
||||||
|
password = models.CharField(max_length=500, blank=True)
|
||||||
|
root_path = models.CharField(max_length=500, blank=True, default='')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"WebDAVSource({self.label}, user={self.user_id})"
|
||||||
|
|
||||||
|
def normalized_base_url(self) -> str:
|
||||||
|
"""Collection root to resolve browse paths against, always ending in '/'.
|
||||||
|
|
||||||
|
A bare host ('https://cloud.example.com') is expanded to the Nextcloud
|
||||||
|
files endpoint, since that is the URL users actually have at hand; an
|
||||||
|
URL that already points into a DAV tree is left alone so non-Nextcloud
|
||||||
|
servers stay usable.
|
||||||
|
"""
|
||||||
|
url = self.base_url.strip()
|
||||||
|
if not url.endswith('/'):
|
||||||
|
url += '/'
|
||||||
|
# Match against the path only — a host literally named "webdav.…" or
|
||||||
|
# "dav.…" must not be mistaken for a URL that already points into a
|
||||||
|
# DAV tree.
|
||||||
|
path = urlsplit(url).path.lower()
|
||||||
|
is_dav = any(marker in path for marker in ('/remote.php/', '/dav/', '/webdav'))
|
||||||
|
if not is_dav and self.username:
|
||||||
|
url += f'remote.php/dav/files/{self.username}/'
|
||||||
|
root = self.root_path.strip().strip('/')
|
||||||
|
if root:
|
||||||
|
url += root + '/'
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_token():
|
||||||
|
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
82
accounts/sync.py
Normal 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
528
accounts/tests.py
Normal 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')
|
||||||
|
|
@ -13,4 +13,10 @@ urlpatterns = [
|
||||||
path('background/upload/', views.upload_background, name='upload_background'),
|
path('background/upload/', views.upload_background, name='upload_background'),
|
||||||
path('background/delete/', views.delete_background, name='delete_background'),
|
path('background/delete/', views.delete_background, name='delete_background'),
|
||||||
path('focus-station/', views.save_focus_station, name='save_focus_station'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from django.conf import settings
|
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.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.http import JsonResponse
|
||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
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 radio import lastfm as lastfm_module
|
||||||
|
|
||||||
|
from .models import ApiToken, WebDAVSource
|
||||||
|
from .webdav import WebDAVError, list_directory
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,6 +75,9 @@ def settings_view(request):
|
||||||
context = {
|
context = {
|
||||||
'profile': profile,
|
'profile': profile,
|
||||||
'has_lastfm': profile.has_lastfm(),
|
'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)
|
return render(request, 'accounts/settings.html', context)
|
||||||
|
|
||||||
|
|
@ -181,6 +188,38 @@ def save_focus_station(request):
|
||||||
return JsonResponse({'ok': True})
|
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
|
@login_required
|
||||||
@require_http_methods(['POST'])
|
@require_http_methods(['POST'])
|
||||||
def lastfm_disconnect(request):
|
def lastfm_disconnect(request):
|
||||||
|
|
@ -189,3 +228,85 @@ def lastfm_disconnect(request):
|
||||||
profile.lastfm_username = ''
|
profile.lastfm_username = ''
|
||||||
profile.save(update_fields=['lastfm_session_key', 'lastfm_username'])
|
profile.save(update_fields=['lastfm_session_key', 'lastfm_username'])
|
||||||
return redirect('settings')
|
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
380
accounts/webdav.py
Normal 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 `<!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).'),
|
||||||
|
)
|
||||||
16
books/migrations/0003_ebookprogress_position_anchor.py
Normal file
16
books/migrations/0003_ebookprogress_position_anchor.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
books/migrations/0004_ebook_is_read.py
Normal file
18
books/migrations/0004_ebook_is_read.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -9,6 +9,7 @@ class EBook(models.Model):
|
||||||
data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes
|
data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes
|
||||||
data_iv = models.CharField(max_length=32) # hex IV for EPUB data
|
data_iv = models.CharField(max_length=32) # hex IV for EPUB data
|
||||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
is_read = models.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['uploaded_at']
|
ordering = ['uploaded_at']
|
||||||
|
|
@ -21,6 +22,7 @@ class EBookProgress(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
|
||||||
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
|
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
|
||||||
scroll_fraction = models.FloatField(default=0.0)
|
scroll_fraction = models.FloatField(default=0.0)
|
||||||
|
position_anchor = models.CharField(max_length=30, blank=True, default='')
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import views, webdav
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.book_list, name='book_list'),
|
path('', views.book_list, name='book_list'),
|
||||||
path('upload/', views.upload_book, name='upload_book'),
|
path('upload/', views.upload_book, name='upload_book'),
|
||||||
|
path('cloud/sources/', webdav.cloud_sources, name='cloud_sources'),
|
||||||
|
path('cloud/<int:pk>/browse/', webdav.cloud_browse, name='cloud_browse'),
|
||||||
|
path('cloud/<int:pk>/fetch/', webdav.cloud_fetch, name='cloud_fetch'),
|
||||||
|
path('metadata-lookup/', views.lookup_book_metadata, name='lookup_book_metadata'),
|
||||||
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
|
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
|
||||||
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
||||||
|
path('<int:pk>/read/', views.set_book_read, name='set_book_read'),
|
||||||
|
path('<int:pk>/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>/progress/', views.save_progress, name='save_book_progress'),
|
||||||
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
|
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
|
||||||
path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'),
|
path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'),
|
||||||
|
|
|
||||||
390
books/views.py
390
books/views.py
|
|
@ -1,6 +1,9 @@
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
@ -15,28 +18,320 @@ def _require_auth(request):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Library-shelf metadata lookup (DNB, falling back to Open Library)
|
||||||
|
#
|
||||||
|
# Only ever called on explicit user request (the "Metadaten" book action), never
|
||||||
|
# automatically — the server briefly sees the plaintext ISBN for this one proxied
|
||||||
|
# request, which is a deliberate, narrow exception to the "server never sees book
|
||||||
|
# content" rule (see CLAUDE.md), made because the encryption's real purpose here is
|
||||||
|
# to keep the operator from being able to see what's on the platform (piracy
|
||||||
|
# liability), not strict user privacy — an ISBN lookup against public library
|
||||||
|
# catalogs doesn't undermine that. Nothing from this lookup is persisted server-side;
|
||||||
|
# the resulting label is stored only in the client's encrypted meta blob.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# DNB "Sachgruppen der Deutschen Nationalbibliografie" / DDC divisions (hundreds -> tens),
|
||||||
|
# used to turn a raw DDC notation like "833.92" into a short shelf label.
|
||||||
|
_DDC_DIVISIONS = {
|
||||||
|
'000': 'Allgemeines, Informatik', '010': 'Bibliografien', '020': 'Bibliotheks- und Informationswissenschaft',
|
||||||
|
'030': 'Enzyklopädien', '050': 'Zeitschriften, fortlaufende Sammelwerke', '060': 'Organisationen, Museumswissenschaft',
|
||||||
|
'070': 'Nachrichtenmedien, Journalismus, Verlagswesen', '090': 'Handschriften, seltene Bücher',
|
||||||
|
'100': 'Philosophie', '130': 'Parapsychologie, Okkultismus', '150': 'Psychologie',
|
||||||
|
'200': 'Religion', '230': 'Christentum, Christliche Theologie', '290': 'Andere Religionen',
|
||||||
|
'300': 'Sozialwissenschaften, Soziologie', '310': 'Statistiken', '320': 'Politikwissenschaft',
|
||||||
|
'330': 'Wirtschaft', '340': 'Recht', '350': 'Öffentliche Verwaltung, Militärwissenschaft',
|
||||||
|
'360': 'Soziale Probleme, Sozialdienste, Versicherungen', '370': 'Erziehung, Schul- und Bildungswesen',
|
||||||
|
'380': 'Handel, Kommunikation, Verkehr', '390': 'Gebräuche, Etikette, Folklore',
|
||||||
|
'400': 'Sprache, Linguistik', '420': 'Englisch', '430': 'Deutsch, Germanische Sprachen',
|
||||||
|
'440': 'Französisch, Romanische Sprachen', '450': 'Italienisch, Rumänisch, Rätoromanisch',
|
||||||
|
'460': 'Spanisch, Portugiesisch', '470': 'Latein, Italische Sprachen', '480': 'Griechisch', '490': 'Andere Sprachen',
|
||||||
|
'500': 'Naturwissenschaften', '510': 'Mathematik', '520': 'Astronomie', '530': 'Physik', '540': 'Chemie',
|
||||||
|
'550': 'Geowissenschaften', '560': 'Paläontologie', '570': 'Biowissenschaften, Biologie',
|
||||||
|
'580': 'Pflanzen (Botanik)', '590': 'Tiere (Zoologie)',
|
||||||
|
'600': 'Technik', '610': 'Medizin, Gesundheit', '620': 'Ingenieurwissenschaften', '630': 'Landwirtschaft',
|
||||||
|
'640': 'Hauswirtschaft', '650': 'Management', '660': 'Chemische Technik', '670': 'Industrielle Fertigung',
|
||||||
|
'680': 'Fertigung für spezielle Zwecke', '690': 'Hausbau, Bauhandwerk',
|
||||||
|
'700': 'Künste', '710': 'Landschaftsgestaltung, Raumplanung', '720': 'Architektur',
|
||||||
|
'730': 'Plastik, Keramik, Metallkunst', '740': 'Zeichnung, angewandte Kunst', '750': 'Malerei',
|
||||||
|
'760': 'Grafik, Druckgrafik, Fotografie', '780': 'Musik', '790': 'Freizeit, Darstellende Kunst, Sport',
|
||||||
|
'800': 'Literatur', '810': 'Amerikanische Literatur', '820': 'Englische Literatur',
|
||||||
|
'830': 'Deutsche Literatur', '840': 'Französische Literatur', '850': 'Italienische Literatur',
|
||||||
|
'860': 'Spanische, Portugiesische Literatur', '870': 'Lateinische Literatur', '880': 'Griechische Literatur',
|
||||||
|
'890': 'Literaturen in anderen Sprachen',
|
||||||
|
'900': 'Geschichte', '910': 'Geografie, Reisen', '920': 'Biografie, Genealogie',
|
||||||
|
'930': 'Geschichte des Altertums', '940': 'Geschichte Europas', '950': 'Geschichte Asiens',
|
||||||
|
'960': 'Geschichte Afrikas', '970': 'Geschichte Nordamerikas', '980': 'Geschichte Südamerikas',
|
||||||
|
'990': 'Geschichte der übrigen Welt',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ddc_label(ddc_raw):
|
||||||
|
"""Coarsen a DDC notation ('833.92') down to its nearest known division/class label."""
|
||||||
|
digits = re.sub(r'\D', '', ddc_raw or '')
|
||||||
|
if len(digits) < 3:
|
||||||
|
return None
|
||||||
|
tens = digits[:2] + '0'
|
||||||
|
if tens in _DDC_DIVISIONS:
|
||||||
|
return _DDC_DIVISIONS[tens]
|
||||||
|
return _DDC_DIVISIONS.get(digits[0] + '00')
|
||||||
|
|
||||||
|
|
||||||
|
_MARC_NS = '{http://www.loc.gov/MARC21/slim}'
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_dnb_shelf(isbn):
|
||||||
|
url = 'https://services.dnb.de/sru/dnb'
|
||||||
|
params = {
|
||||||
|
'version': '1.1',
|
||||||
|
'operation': 'searchRetrieve',
|
||||||
|
'query': f'dnb.num={isbn}',
|
||||||
|
'recordSchema': 'MARC21-xml',
|
||||||
|
'maximumRecords': '1',
|
||||||
|
}
|
||||||
|
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
||||||
|
resp.raise_for_status()
|
||||||
|
root = ET.fromstring(resp.content)
|
||||||
|
for datafield in root.iter(f'{_MARC_NS}datafield'):
|
||||||
|
if datafield.get('tag') != '082':
|
||||||
|
continue
|
||||||
|
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
|
||||||
|
if subfield.get('code') == 'a' and subfield.text:
|
||||||
|
label = _ddc_label(subfield.text)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_openlibrary_shelf(isbn):
|
||||||
|
url = 'https://openlibrary.org/api/books'
|
||||||
|
params = {'bibkeys': f'ISBN:{isbn}', 'jscmd': 'data', 'format': 'json'}
|
||||||
|
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json().get(f'ISBN:{isbn}', {})
|
||||||
|
ddc = (data.get('classifications') or {}).get('dewey_decimal_class') or []
|
||||||
|
if ddc:
|
||||||
|
label = _ddc_label(ddc[0])
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
subjects = data.get('subjects') or []
|
||||||
|
if subjects:
|
||||||
|
return subjects[0].get('name')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cql_phrase(value):
|
||||||
|
# CQL string literals are quoted; strip embedded quotes rather than escaping them
|
||||||
|
# (this only feeds a lookup heuristic, not a stored/displayed value).
|
||||||
|
return value.replace('"', ' ').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_dnb_shelf_by_title(title, author):
|
||||||
|
url = 'https://services.dnb.de/sru/dnb'
|
||||||
|
query = f'dnb.tit="{_cql_phrase(title)}"'
|
||||||
|
if author:
|
||||||
|
query += f' and dnb.per="{_cql_phrase(author)}"'
|
||||||
|
params = {
|
||||||
|
'version': '1.1',
|
||||||
|
'operation': 'searchRetrieve',
|
||||||
|
'query': query,
|
||||||
|
'recordSchema': 'MARC21-xml',
|
||||||
|
'maximumRecords': '1',
|
||||||
|
}
|
||||||
|
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
||||||
|
resp.raise_for_status()
|
||||||
|
root = ET.fromstring(resp.content)
|
||||||
|
for datafield in root.iter(f'{_MARC_NS}datafield'):
|
||||||
|
if datafield.get('tag') != '082':
|
||||||
|
continue
|
||||||
|
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
|
||||||
|
if subfield.get('code') == 'a' and subfield.text:
|
||||||
|
label = _ddc_label(subfield.text)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_openlibrary_shelf_by_title(title, author):
|
||||||
|
url = 'https://openlibrary.org/search.json'
|
||||||
|
params = {'title': title, 'limit': 1, 'fields': 'ddc,subject'}
|
||||||
|
if author:
|
||||||
|
params['author'] = author
|
||||||
|
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
||||||
|
resp.raise_for_status()
|
||||||
|
docs = resp.json().get('docs') or []
|
||||||
|
if not docs:
|
||||||
|
return None
|
||||||
|
doc = docs[0]
|
||||||
|
ddc = doc.get('ddc') or []
|
||||||
|
if ddc:
|
||||||
|
label = _ddc_label(ddc[0])
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
subjects = doc.get('subject') or []
|
||||||
|
if subjects:
|
||||||
|
return subjects[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(['GET'])
|
||||||
|
def lookup_book_metadata(request):
|
||||||
|
err = _require_auth(request)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
|
isbn_raw = request.GET.get('isbn', '').strip()
|
||||||
|
title = request.GET.get('title', '').strip()
|
||||||
|
author = request.GET.get('author', '').strip()
|
||||||
|
isbn = re.sub(r'[^0-9Xx]', '', isbn_raw)
|
||||||
|
|
||||||
|
if isbn_raw and len(isbn) not in (10, 13):
|
||||||
|
return JsonResponse({'error': 'invalid ISBN'}, status=400)
|
||||||
|
if not isbn and not title:
|
||||||
|
return JsonResponse({'error': 'isbn or title required'}, status=400)
|
||||||
|
|
||||||
|
label = None
|
||||||
|
source = None
|
||||||
|
|
||||||
|
# ISBN is the precise path — try it first when we have one.
|
||||||
|
if isbn:
|
||||||
|
try:
|
||||||
|
label = _lookup_dnb_shelf(isbn)
|
||||||
|
if label:
|
||||||
|
source = 'dnb'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not label:
|
||||||
|
try:
|
||||||
|
label = _lookup_openlibrary_shelf(isbn)
|
||||||
|
if label:
|
||||||
|
source = 'openlibrary'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# No ISBN (or it drew a blank) — fall back to a title/author text search. Less
|
||||||
|
# precise (wrong edition/translation is possible), so the source is tagged
|
||||||
|
# distinctly for the client to hint at that if it wants to.
|
||||||
|
if not label and title:
|
||||||
|
try:
|
||||||
|
label = _lookup_dnb_shelf_by_title(title, author)
|
||||||
|
if label:
|
||||||
|
source = 'dnb-title'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not label:
|
||||||
|
try:
|
||||||
|
label = _lookup_openlibrary_shelf_by_title(title, author)
|
||||||
|
if label:
|
||||||
|
source = 'openlibrary-title'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return JsonResponse({'label': label, 'source': source})
|
||||||
|
|
||||||
|
|
||||||
|
def _anchor_parts(anchor):
|
||||||
|
"""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'])
|
@require_http_methods(['GET'])
|
||||||
def book_list(request):
|
def book_list(request):
|
||||||
err = _require_auth(request)
|
err = _require_auth(request)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
books = list(
|
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:
|
for b in books:
|
||||||
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
||||||
# Include saved scroll_fraction for each book
|
# Include saved scroll_fraction for each book
|
||||||
progress_map = {
|
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)
|
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:
|
for b in books:
|
||||||
prog = progress_map.get(b['id'])
|
prog = progress_map.get(b['id'])
|
||||||
b['scroll_fraction'] = prog[0] if prog else 0.0
|
b['scroll_fraction'] = prog[0] if prog else 0.0
|
||||||
b['last_read'] = prog[1].isoformat() if prog else None
|
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)
|
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
|
@csrf_exempt
|
||||||
@require_http_methods(['POST'])
|
@require_http_methods(['POST'])
|
||||||
def upload_book(request):
|
def upload_book(request):
|
||||||
|
|
@ -65,7 +360,7 @@ def upload_book(request):
|
||||||
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
|
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
|
||||||
|
|
||||||
if raw_size > max_bytes:
|
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(
|
book = EBook.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
|
|
@ -74,6 +369,7 @@ def upload_book(request):
|
||||||
data_ct=data_ct,
|
data_ct=data_ct,
|
||||||
data_iv=data_iv,
|
data_iv=data_iv,
|
||||||
)
|
)
|
||||||
|
EBookProgress.objects.create(user=request.user, book=book)
|
||||||
return JsonResponse({'ok': True, 'id': book.id})
|
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})
|
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
|
@csrf_exempt
|
||||||
@require_http_methods(['POST'])
|
@require_http_methods(['POST'])
|
||||||
def delete_book(request, pk):
|
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 = float(body.get('scroll_fraction', 0.0))
|
||||||
scroll_fraction = max(0.0, min(1.0, scroll_fraction))
|
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(
|
progress, _ = EBookProgress.objects.get_or_create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
book=book,
|
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.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})
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
@ -172,7 +550,7 @@ def book_highlights(request, pk):
|
||||||
raw_size = len(base64.b64decode(ct))
|
raw_size = len(base64.b64decode(ct))
|
||||||
except Exception:
|
except Exception:
|
||||||
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
|
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)
|
return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400)
|
||||||
|
|
||||||
row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book)
|
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))
|
raw_size = len(base64.b64decode(ct))
|
||||||
except Exception:
|
except Exception:
|
||||||
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
|
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)
|
return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400)
|
||||||
|
|
||||||
row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book)
|
row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book)
|
||||||
|
|
|
||||||
89
books/webdav.py
Normal file
89
books/webdav.py
Normal 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
|
||||||
|
|
@ -3,3 +3,11 @@ from django.conf import settings
|
||||||
|
|
||||||
def build_info(request):
|
def build_info(request):
|
||||||
return {'BUILD_TIME': getattr(settings, 'BUILD_TIME', '')}
|
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),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,10 @@ INSTALLED_APPS = [
|
||||||
'gpodder',
|
'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
|
# Encrypted uploads are base64-encoded (~33% overhead) so allow ~75 MB body
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 25 * 1024 * 1024
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 75 * 1024 * 1024
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
|
@ -44,6 +44,7 @@ MIDDLEWARE = [
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'accounts.middleware.ApiTokenAuthMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
|
|
@ -62,6 +63,7 @@ TEMPLATES = [
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
'diora.context_processors.build_info',
|
'diora.context_processors.build_info',
|
||||||
|
'diora.context_processors.upload_limits',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -72,7 +74,7 @@ WSGI_APPLICATION = 'diora.wsgi.application'
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'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},
|
'OPTIONS': {'timeout': 20},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,6 +103,15 @@ MEDIA_URL = '/media/'
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
BG_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
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'
|
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_TAG = os.environ.get('AMAZON_AFFILIATE_TAG', 'diora-20')
|
||||||
AMAZON_AFFILIATE_ENABLED = os.environ.get('AMAZON_AFFILIATE_ENABLED', 'True') == 'True'
|
AMAZON_AFFILIATE_ENABLED = os.environ.get('AMAZON_AFFILIATE_ENABLED', 'True') == 'True'
|
||||||
|
|
||||||
|
# WebDAV cloud import (ebooks). Users supply the target URL, so by default the
|
||||||
|
# server refuses to talk to non-public addresses — otherwise any registered
|
||||||
|
# account could probe the internal network through it. Set to True only when
|
||||||
|
# every account on this instance is trusted and the WebDAV server is on the LAN.
|
||||||
|
WEBDAV_ALLOW_PRIVATE_HOSTS = os.environ.get('WEBDAV_ALLOW_PRIVATE_HOSTS', 'False') == 'True'
|
||||||
|
|
||||||
BUILD_TIME = os.environ.get('BUILD_TIME', '')
|
BUILD_TIME = os.environ.get('BUILD_TIME', '')
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
from django.views.static import serve as serve_static
|
||||||
|
|
||||||
|
from accounts.sync import sync_snapshot
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
|
@ -9,5 +12,12 @@ urlpatterns = [
|
||||||
path('podcasts/', include('podcasts.urls')),
|
path('podcasts/', include('podcasts.urls')),
|
||||||
path('books/', include('books.urls')),
|
path('books/', include('books.urls')),
|
||||||
path('api/2/', include('gpodder.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')),
|
path('', include('radio.urls')),
|
||||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
|
||||||
25
e2e/authenticated.spec.js
Normal file
25
e2e/authenticated.spec.js
Normal 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
18
e2e/env.js
Normal 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
60
e2e/global-setup.js
Normal 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
37
e2e/smoke.spec.js
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -143,7 +143,7 @@ def subscriptions_by_device(request, username, deviceid):
|
||||||
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
|
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
|
||||||
else:
|
else:
|
||||||
urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True))
|
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':
|
elif request.method == 'POST':
|
||||||
try:
|
try:
|
||||||
|
|
@ -178,7 +178,7 @@ def subscriptions_all(request, username):
|
||||||
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
|
return JsonResponse({'add': added, 'remove': removed, 'timestamp': _now_ts(), 'update_urls': []})
|
||||||
else:
|
else:
|
||||||
urls = list(PodcastFeed.objects.filter(user=request.user).values_list('rss_url', flat=True))
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return JsonResponse({'timestamp': _now_ts()})
|
return JsonResponse({'timestamp': _now_ts(), 'update_urls': []})
|
||||||
|
|
||||||
elif request.method == 'GET':
|
elif request.method == 'GET':
|
||||||
since = request.GET.get('since')
|
since = request.GET.get('since')
|
||||||
|
|
|
||||||
79
package-lock.json
generated
Normal file
79
package-lock.json
generated
Normal 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
20
package.json
Normal 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
37
playwright.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -145,7 +145,7 @@ def podcast_search(request):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = f'https://itunes.apple.com/search?term={urllib.parse.quote(q)}&media=podcast&limit=20'
|
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()
|
resp.raise_for_status()
|
||||||
raw = resp.json().get('results', [])
|
raw = resp.json().get('results', [])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -620,7 +620,7 @@ def inbox(request):
|
||||||
).values_list('episode_id', flat=True)
|
).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)
|
offset = max(int(request.GET.get('offset', 0)), 0)
|
||||||
|
|
||||||
episodes = list(
|
episodes = list(
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,5 @@ urlpatterns = [
|
||||||
path('radio/notes/<int:pk>/', views.save_station_notes, name='save_station_notes'),
|
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/record/', views.record_focus_session, name='record_focus_session'),
|
||||||
path('radio/focus/stats/', views.focus_stats, name='focus_stats'),
|
path('radio/focus/stats/', views.focus_stats, name='focus_stats'),
|
||||||
|
path('radio/stream-player/', views.stream_player, name='stream_player'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
|
import ssl as ssl_module
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -204,7 +206,7 @@ def affiliate_links(request):
|
||||||
f"https://itunes.apple.com/search"
|
f"https://itunes.apple.com/search"
|
||||||
f"?term={urllib.parse.quote(track)}&media=music&limit=1"
|
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()
|
resp.raise_for_status()
|
||||||
results = resp.json().get('results', [])
|
results = resp.json().get('results', [])
|
||||||
if results:
|
if results:
|
||||||
|
|
@ -573,3 +575,19 @@ def import_m3u(request):
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
|
||||||
return JsonResponse({'ok': True, 'added': added, 'skipped': skipped})
|
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})
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,17 @@
|
||||||
padding: 0;
|
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 {
|
:root {
|
||||||
--bg: #000;
|
--bg: #000;
|
||||||
--bg-card: transparent;
|
--bg-card: transparent;
|
||||||
|
|
@ -66,7 +77,8 @@ html, body {
|
||||||
.btn-lastfm,
|
.btn-lastfm,
|
||||||
.btn-danger,
|
.btn-danger,
|
||||||
.btn-primary,
|
.btn-primary,
|
||||||
.navbar-brand {
|
.navbar-brand,
|
||||||
|
.reader-toast {
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,8 +108,16 @@ a:hover {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: var(--nav-h);
|
height: var(--nav-h);
|
||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
background: transparent;
|
background: var(--bg);
|
||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.navbar-brand {
|
||||||
|
|
@ -130,7 +150,9 @@ a:hover {
|
||||||
.main-content {
|
.main-content {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
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;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: var(--bar-h);
|
height: var(--bar-h);
|
||||||
background: transparent;
|
background: var(--bg);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -294,6 +316,18 @@ a:hover {
|
||||||
gap: 0;
|
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 {
|
.tab-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -676,6 +710,161 @@ a:hover {
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- WebDAV cloud connections (settings) --- */
|
||||||
|
|
||||||
|
.webdav-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webdav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webdav-item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webdav-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webdav-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webdav-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webdav-field input {
|
||||||
|
background: var(--bg-alt);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webdav-field input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webdav-form .btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cloud import browser (books tab) --- */
|
||||||
|
|
||||||
|
.cloud-browser {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-browser-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-browser-head select {
|
||||||
|
background: var(--bg-alt);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-path {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-entries {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-entry-name {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
word-break: break-all;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-entry-name:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-entry-size {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
@ -715,7 +904,10 @@ a:hover {
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.main-content {
|
.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 {
|
.now-playing-bar {
|
||||||
|
|
@ -734,8 +926,10 @@ a:hover {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider {
|
.volume-label,
|
||||||
width: 60px;
|
.volume-slider,
|
||||||
|
.volume-num {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.affiliate-section {
|
.affiliate-section {
|
||||||
|
|
@ -1316,6 +1510,39 @@ body.dnd-mode .timer-display {
|
||||||
line-height: 1.6;
|
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 */
|
/* Style links and basic HTML inside shownotes */
|
||||||
.sidebar-body a { color: var(--accent, #e63946); }
|
.sidebar-body a { color: var(--accent, #e63946); }
|
||||||
.sidebar-body p { margin: 0 0 10px; }
|
.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;
|
padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-marker-btn-mobile { display: none; }
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.sidebar { width: 100vw; }
|
.sidebar { width: 100vw; }
|
||||||
.podcast-seek-bar { padding: 0 6px; }
|
.podcast-seek-bar { padding: 0 6px; }
|
||||||
|
|
@ -1416,6 +1645,19 @@ body.dnd-mode .timer-display {
|
||||||
.podcast-thumb-lg { width: 60px; height: 60px; }
|
.podcast-thumb-lg { width: 60px; height: 60px; }
|
||||||
.podcast-feed-actions { flex-direction: column; }
|
.podcast-feed-actions { flex-direction: column; }
|
||||||
.episode-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;
|
padding: 24px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offline-notice {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.book-key-bar {
|
.book-key-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1490,11 +1738,138 @@ body.dnd-mode .timer-display {
|
||||||
.book-progress {
|
.book-progress {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.book-item-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.book-item-actions {
|
.book-item-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.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 pages --- */
|
||||||
.pdf-page-wrapper {
|
.pdf-page-wrapper {
|
||||||
|
|
@ -1520,6 +1895,11 @@ body.dnd-mode .timer-display {
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.reader-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1552,6 +1932,8 @@ body.dnd-mode .timer-display {
|
||||||
.reader-content {
|
.reader-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
overflow-x: auto;
|
||||||
|
position: relative;
|
||||||
padding: 24px 16px;
|
padding: 24px 16px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
|
@ -1572,23 +1954,13 @@ body.dnd-mode .timer-display {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Focus station sidebar --- */
|
/* --- Focus station sidebar --- */
|
||||||
.focus-preset-list {
|
/* --- Radio sidebar --- */
|
||||||
list-style: none;
|
.rsb-nowplaying { margin-bottom: 12px; }
|
||||||
display: flex;
|
.rsb-station-name { font-weight: 600; }
|
||||||
flex-direction: column;
|
.rsb-track { font-size: 0.85rem; margin-top: 2px; }
|
||||||
gap: 6px;
|
.rsb-controls { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||||
margin: 10px 0;
|
.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; }
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Table of contents sidebar --- */
|
/* --- Table of contents sidebar --- */
|
||||||
.toc-list {
|
.toc-list {
|
||||||
|
|
@ -1653,22 +2025,172 @@ body.dnd-mode .timer-display {
|
||||||
/* PDF invert */
|
/* PDF invert */
|
||||||
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); }
|
#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 */
|
/* PDF paginated */
|
||||||
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
|
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
|
||||||
.pdf-paginated .pdf-page-wrapper { margin:0; }
|
.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 */
|
||||||
.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); }
|
.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 { 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-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; }
|
.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 */
|
/* Highlight marks */
|
||||||
.epub-highlight { border-radius:2px; cursor:pointer; }
|
.reader-no-bold * { font-weight: normal !important; }
|
||||||
.epub-highlight[data-color="yellow"] { background:rgba(241,196,15,.4); }
|
.reader-content img { max-width: 100%; height: auto; display: block; }
|
||||||
.epub-highlight[data-color="green"] { background:rgba(46,204,113,.35); }
|
/* color:inherit overrides the browser's UA-default `mark { color: black }` —
|
||||||
.epub-highlight[data-color="blue"] { background:rgba(52,152,219,.35); }
|
without it, highlighted text abruptly flips to black regardless of the
|
||||||
.epub-highlight[data-color="red"] { background:rgba(230,57,70,.35); }
|
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 */
|
/* Search matches */
|
||||||
mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border-radius:2px; }
|
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; }
|
.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} }
|
@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 {
|
.build-time {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
|
|
|
||||||
2879
static/js/app.js
2879
static/js/app.js
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v7';
|
const CACHE = 'diora-v41';
|
||||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
@ -31,6 +31,13 @@ self.addEventListener('activate', function (event) {
|
||||||
);
|
);
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
return self.clients.claim();
|
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;
|
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; });
|
const isShell = SHELL.some(function (s) { return url.pathname === s; });
|
||||||
if (isShell) {
|
if (isShell) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
|
|
@ -79,5 +86,22 @@ self.addEventListener('fetch', function (event) {
|
||||||
return cached || fetch(event.request);
|
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);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"description": "Internet radio player",
|
"description": "Internet radio player",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"display_override": ["window-controls-overlay", "standalone"],
|
||||||
"background_color": "#000000",
|
"background_color": "#000000",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
|
|
@ -12,13 +13,25 @@
|
||||||
"src": "/static/icon-192.png",
|
"src": "/static/icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"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",
|
"src": "/static/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,128 @@
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2>Account</h2>
|
<h2>Account</h2>
|
||||||
<p>Logged in as <strong>{{ request.user.username }}</strong></p>
|
<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 %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn">Logout</button>
|
<button type="submit" class="btn">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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 <token></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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -121,6 +238,11 @@ async function _getOrCreateEncKey() {
|
||||||
return key;
|
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) {
|
async function uploadBackground(input) {
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||||
<link rel="stylesheet" href="/static/css/app.css">
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
<title>{% block title %}diora{% endblock %}</title>
|
<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 %}
|
{% if encrypted_bg_json %}
|
||||||
<script>const ENCRYPTED_BG = {{ encrypted_bg_json|safe }};</script>
|
<script>const ENCRYPTED_BG = {{ encrypted_bg_json|safe }};</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,14 @@
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ 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="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>
|
||||||
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
|
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
|
||||||
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">⏪ 15</button>
|
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">« 15</button>
|
||||||
<span class="seek-time" id="seek-current">0:00</span>
|
<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">
|
<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>
|
<span class="seek-time" id="seek-duration">0:00</span>
|
||||||
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30 ⏩</button>
|
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30 »</button>
|
||||||
<div class="speed-btns" id="speed-btns">
|
<div class="speed-btns" id="speed-btns">
|
||||||
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</button>
|
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</button>
|
||||||
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</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-toggle-btn" onclick="toggleTimer()" title="Start/pause timer">▶</button>
|
||||||
<button class="btn-icon" id="timer-reset-btn" onclick="resetTimer()" title="Reset 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>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -131,7 +131,7 @@
|
||||||
<table class="data-table" id="saved-table">
|
<table class="data-table" id="saved-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>★</th>
|
<th title="Favorite">★</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Bitrate</th>
|
<th>Bitrate</th>
|
||||||
<th>Country</th>
|
<th>Country</th>
|
||||||
|
|
@ -319,7 +319,21 @@
|
||||||
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
||||||
<span id="book-upload-status" class="muted"></span>
|
<span id="book-upload-status" class="muted"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn" style="margin:0.5rem 0;" onclick="toggleCloudImport()">☁ Aus Cloud importieren</button>
|
||||||
|
<div id="cloud-browser" class="cloud-browser" style="display:none;">
|
||||||
|
<div class="cloud-browser-head">
|
||||||
|
<select id="cloud-source-select" onchange="_onCloudSourceChange(this.value)"></select>
|
||||||
|
<button type="button" class="btn" onclick="closeCloudImport()">Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
<div id="book-list" class="book-list"></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="auth-prompt">
|
<p class="auth-prompt">
|
||||||
|
|
@ -335,19 +349,35 @@
|
||||||
<span id="reader-title" class="reader-title"></span>
|
<span id="reader-title" class="reader-title"></span>
|
||||||
<div class="reader-header-actions">
|
<div class="reader-header-actions">
|
||||||
<span class="reader-progress-wrap">
|
<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 id="reader-progress-suffix" class="muted"></span>
|
||||||
</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 & layout">⚙</button>
|
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font & layout">⚙</button>
|
||||||
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
|
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
|
||||||
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks">☰</button>
|
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks">▤</button>
|
||||||
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button>
|
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button>
|
||||||
|
<button class="btn-icon" id="reader-margin-btn" onclick="toggleAnnotationsMargin()" title="Notes & 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>
|
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</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 & 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 id="reader-content" class="reader-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===== SIDEBAR ===== -->
|
<!-- ===== SIDEBAR ===== -->
|
||||||
<div id="sidebar-overlay" class="sidebar-overlay" onclick="closeSidebar()" style="display:none;"></div>
|
<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>
|
<div id="sidebar-body" class="sidebar-body"></div>
|
||||||
</aside>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
|
|
||||||
162
templates/radio/stream_player.html
Normal file
162
templates/radio/stream_player.html
Normal 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">▶ 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()">← 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 = '▮▮ 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 = '▶ Play';
|
||||||
|
document.getElementById('track-name').textContent = 'Click Play to start';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPlay() {
|
||||||
|
audio.pause();
|
||||||
|
audio.src = '';
|
||||||
|
playing = false;
|
||||||
|
playBtn.innerHTML = '▶ 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>
|
||||||
Loading…
Add table
Reference in a new issue