', published=timezone.now())
+ logins._sessions.clear()
+
+ def post(self, url, data):
+ return self.client.post(url, json.dumps(data), content_type='application/json')
+
+ def test_encryption_round_trip_and_not_plain(self):
+ token = logins.encrypt('geheim')
+ self.assertNotIn('geheim', token)
+ self.assertEqual(logins.decrypt(token), 'geheim')
+ self.assertIsNone(logins.decrypt('kaputt'))
+
+ def test_paywalled_page_keeps_free_part_and_waits(self):
+ with mock.patch('requests.get', return_value=FakeResponse(200, text=PAYWALL)):
+ refresh.load_article(self.item)
+ self.item.refresh_from_db()
+ self.assertTrue(self.item.paywalled)
+ self.assertFalse(self.item.content_full)
+ self.assertIn('Freier Anfang', self.item.content)
+ self.assertFalse(refresh.needs_article(self.item, self.src)) # no login: don't try again
+
+ def test_wrong_login_is_refused_and_not_stored(self):
+ with mock.patch('requests.Session.post', return_value=FakeResponse(302, 'https://www.jungewelt.de/loginFailed.php?x')):
+ r = self.post('/feeds/logins/', {'source': self.src.id, 'username': 'u', 'password': 'falsch'})
+ self.assertEqual(r.status_code, 400)
+ self.assertIn('falsch', r.json()['error'])
+ self.assertFalse(SiteLogin.objects.exists())
+
+ def test_login_stores_encrypted_and_refetches_full_articles(self):
+ Item.objects.filter(pk=self.item.pk).update(paywalled=True)
+
+ def login_post(session, url, **kw):
+ session.cookies.set('jW', 'sitzung')
+ return FakeResponse(302, 'https://www.jungewelt.de/')
+
+ with mock.patch('requests.Session.post', autospec=True, side_effect=login_post), \
+ mock.patch('requests.Session.get', return_value=FakeResponse(200, text=FULL)):
+ r = self.post('/feeds/logins/', {'source': self.src.id, 'username': 'leser', 'password': 'abo-passwort'})
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.json()['refetched'], 1)
+ login = SiteLogin.objects.get(user=self.u)
+ self.assertNotIn('abo-passwort', login.secret)
+ self.item.refresh_from_db()
+ self.assertTrue(self.item.content_full)
+ self.assertFalse(self.item.paywalled)
+ self.assertIn('ganze Artikel', self.item.content)
+ state = self.client.get('/feeds/state/').json()
+ info = state['sources'][0]['login']
+ self.assertEqual((info['username'], info['ok'], info['site']), ('leser', True, 'junge Welt'))
+ self.assertNotIn('abo-passwort', json.dumps(state))
+ self.post('/feeds/logins/delete/', {'source': self.src.id})
+ self.assertFalse(SiteLogin.objects.exists())
+
+ def test_sources_of_other_sites_have_no_login(self):
+ other = Source.objects.create(user=self.u, url='https://blog.example/feed', title='B', kind='blog')
+ state = self.client.get('/feeds/state/').json()
+ by_id = {s['id']: s for s in state['sources']}
+ self.assertIsNone(by_id[other.id]['login'])
+ self.assertIsNotNone(by_id[self.src.id]['login'])
diff --git a/feeds/urls.py b/feeds/urls.py
index aa3d3f0..ebbe95f 100644
--- a/feeds/urls.py
+++ b/feeds/urls.py
@@ -21,4 +21,6 @@ urlpatterns = [
path('items//', views.item, name='feeds_item'),
path('items//star/', views.star, name='feeds_star'),
path('settings/', views.settings, name='feeds_settings'),
+ path('logins/', views.save_login, name='feeds_save_login'),
+ path('logins/delete/', views.delete_login, name='feeds_delete_login'),
]
diff --git a/feeds/views.py b/feeds/views.py
index 31fccf3..6a676a4 100644
--- a/feeds/views.py
+++ b/feeds/views.py
@@ -18,6 +18,8 @@ Items: GET /feeds/items// the article (fetches the page if t
POST /feeds/items//star/ {starred}
GET /feeds/starred/
Settings: POST /feeds/settings/ {news_keep_days}
+Logins: POST /feeds/logins/ {source, username, password} subscriber login for its site
+ POST /feeds/logins/delete/ {source}
"""
import json
@@ -34,8 +36,8 @@ from django.utils.dateparse import parse_datetime
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
-from . import fetch, refresh
-from .models import Item, Source, Story
+from . import fetch, logins, refresh
+from .models import Item, SiteLogin, Source, Story
PAGE = 40
BRIEFING_TOP = 8
@@ -66,6 +68,7 @@ def _source_data(s, unread=0):
'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url,
'kind': s.kind, 'category': s.category, 'fulltext': s.fulltext, 'unread': unread,
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
+ 'login': logins.info(s.user_id, s.url),
}
@@ -79,6 +82,7 @@ def _item_data(i, content=False):
if content:
d['content'] = i.content
d['content_full'] = i.content_full
+ d['paywalled'] = i.paywalled
return d
@@ -435,6 +439,46 @@ def star(request, pk):
return JsonResponse({'updated': n})
+@csrf_exempt
+@require_http_methods(['POST'])
+def save_login(request):
+ """Check a subscriber login for the source's site and keep it; then the
+ articles that came only in part are fetched again."""
+ if (err := _auth(request)):
+ return err
+ body = _body(request) or {}
+ source = Source.objects.filter(user=request.user, pk=body.get('source')).first()
+ username = (body.get('username') or '').strip()
+ password = body.get('password') or ''
+ if source is None or not username or not password:
+ return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400)
+ try:
+ adapter = logins.check_and_store(request.user, source.url, username, password)
+ except logins.LoginFailed as e:
+ return JsonResponse({'error': str(e)}, status=400)
+ # Whatever came in part (or not at all) from this site: fetch again, the newest now.
+ items = Item.objects.filter(user=request.user, gone=False, content_full=False, url__icontains=adapter.domain)
+ items.update(fulltext_failed=False)
+ todo = list(items.select_related('source').order_by('-published')[:40])
+ refresh._articles(todo, deadline=time.monotonic() + 30)
+ done = Item.objects.filter(pk__in=[i.pk for i in todo], content_full=True).count()
+ return JsonResponse({'ok': True, 'login': logins.info(request.user.id, source.url), 'refetched': done})
+
+
+@csrf_exempt
+@require_http_methods(['POST'])
+def delete_login(request):
+ if (err := _auth(request)):
+ return err
+ body = _body(request) or {}
+ source = Source.objects.filter(user=request.user, pk=body.get('source')).first()
+ adapter = logins.adapter_for(source.url) if source else None
+ if adapter:
+ SiteLogin.objects.filter(user=request.user, domain=adapter.domain).delete()
+ logins.forget(request.user.id, adapter.domain)
+ return JsonResponse({'ok': True})
+
+
@csrf_exempt
@require_http_methods(['POST'])
def settings(request):
diff --git a/requirements.txt b/requirements.txt
index c08aad3..caa4915 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,3 +21,5 @@ piper-tts==1.7.0
imageio-ffmpeg==0.6.0
# The feed reader takes the article out of a news page (main text without menus, ads, comments).
trafilatura==2.3.0
+# Subscriber logins of the feed reader are kept encrypted (feeds/logins.py).
+cryptography==50.0.1
diff --git a/static/js/feeds.js b/static/js/feeds.js
index 2d85db2..b591a33 100644
--- a/static/js/feeds.js
+++ b/static/js/feeds.js
@@ -330,7 +330,29 @@
'' +
'
' +
'' +
- '
';
+ '' +
+ loginForm(x);
+ }
+
+ // A paywalled site (junge Welt …): the subscriber login, kept encrypted on the
+ // server, which then fetches whole articles. The password never comes back.
+ function loginForm(x) {
+ const l = x.login;
+ if (!l) return '';
+ let h = '';
+ h += '
Neu eingeben:
';
+ }
+ return h +
+ '' +
+ '' +
+ '
Das Passwort liegt verschlüsselt auf dem diora-Server und wird nie wieder angezeigt. ' +
+ 'Damit holt der Server die ganzen Artikel statt nur des freien Teils.
' +
+ '';
}
function renderOpml() {
@@ -449,7 +471,10 @@
const content = i.content || '';
if (i.image_url && content.indexOf(i.image_url) < 0) h += '';
h += '
' + (content || '
' + esc(i.summary) + '
') + '
';
- if (!i.content_full && i.url && (content.replace(/<[^>]+>/g, '').length < 700)) {
+ if (i.paywalled) {
+ h += '
Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
+ ') holt diora den ganzen.
';
+ } else if (!i.content_full && i.url && (content.replace(/<[^>]+>/g, '').length < 700)) {
h += '
Der Feed bringt nur einen Anriss, der ganze Artikel ließ sich nicht laden.