From 54ce6d5fe63ab591ea33ddec91e2b4250786e422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Sat, 23 Jul 2022 23:20:27 +0200 Subject: [PATCH] Add RSS feeds (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add basic unread bookmarks feed * Generate user-specific feed * Add feed tests * Add all bookmarks feed * Add feed token admin * Add note about renewing URLs * Add support for query parameter * Fix rebase issues * Improve docs on feeds integration Co-authored-by: Sascha Ißbrücker --- bookmarks/admin.py | 9 +- bookmarks/feeds.py | 56 +++++ bookmarks/migrations/0015_feedtoken.py | 24 +++ bookmarks/models.py | 26 +++ .../templates/settings/integrations.html | 28 ++- bookmarks/tests/helpers.py | 5 +- bookmarks/tests/test_feeds.py | 191 ++++++++++++++++++ .../tests/test_settings_integrations_view.py | 26 +++ bookmarks/urls.py | 6 +- bookmarks/views/settings.py | 9 +- 10 files changed, 371 insertions(+), 9 deletions(-) create mode 100644 bookmarks/feeds.py create mode 100644 bookmarks/migrations/0015_feedtoken.py create mode 100644 bookmarks/tests/test_feeds.py diff --git a/bookmarks/admin.py b/bookmarks/admin.py index a9be988..95eba0f 100644 --- a/bookmarks/admin.py +++ b/bookmarks/admin.py @@ -9,7 +9,7 @@ from django.utils.translation import ngettext, gettext from rest_framework.authtoken.admin import TokenAdmin from rest_framework.authtoken.models import TokenProxy -from bookmarks.models import Bookmark, Tag, UserProfile, Toast +from bookmarks.models import Bookmark, Tag, UserProfile, Toast, FeedToken from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark @@ -121,11 +121,18 @@ class AdminToast(admin.ModelAdmin): list_filter = ('owner__username',) +class AdminFeedToken(admin.ModelAdmin): + list_display = ('key', 'user') + search_fields = ['key'] + list_filter = ('user__username',) + + linkding_admin_site = LinkdingAdminSite() linkding_admin_site.register(Bookmark, AdminBookmark) linkding_admin_site.register(Tag, AdminTag) linkding_admin_site.register(User, AdminCustomUser) linkding_admin_site.register(TokenProxy, TokenAdmin) linkding_admin_site.register(Toast, AdminToast) +linkding_admin_site.register(FeedToken, AdminFeedToken) linkding_admin_site.register(Task, TaskAdmin) linkding_admin_site.register(CompletedTask, CompletedTaskAdmin) diff --git a/bookmarks/feeds.py b/bookmarks/feeds.py new file mode 100644 index 0000000..883721f --- /dev/null +++ b/bookmarks/feeds.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass + +from django.contrib.syndication.views import Feed +from django.db.models import QuerySet +from django.urls import reverse + +from bookmarks.models import Bookmark, FeedToken +from bookmarks import queries + + +@dataclass +class FeedContext: + feed_token: FeedToken + query_set: QuerySet[Bookmark] + + +class BaseBookmarksFeed(Feed): + def get_object(self, request, feed_key: str): + feed_token = FeedToken.objects.get(key__exact=feed_key) + query_string = request.GET.get('q') + query_set = queries.query_bookmarks(feed_token.user, query_string) + return FeedContext(feed_token, query_set) + + def item_title(self, item: Bookmark): + return item.resolved_title + + def item_description(self, item: Bookmark): + return item.resolved_description + + def item_link(self, item: Bookmark): + return item.url + + def item_pubdate(self, item: Bookmark): + return item.date_added + + +class AllBookmarksFeed(BaseBookmarksFeed): + title = 'All bookmarks' + description = 'All bookmarks' + + def link(self, context: FeedContext): + return reverse('bookmarks:feeds.all', args=[context.feed_token.key]) + + def items(self, context: FeedContext): + return context.query_set + + +class UnreadBookmarksFeed(BaseBookmarksFeed): + title = 'Unread bookmarks' + description = 'All unread bookmarks' + + def link(self, context: FeedContext): + return reverse('bookmarks:feeds.unread', args=[context.feed_token.key]) + + def items(self, context: FeedContext): + return context.query_set.filter(unread=True) diff --git a/bookmarks/migrations/0015_feedtoken.py b/bookmarks/migrations/0015_feedtoken.py new file mode 100644 index 0000000..15b4e0f --- /dev/null +++ b/bookmarks/migrations/0015_feedtoken.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.13 on 2022-07-23 20:35 + +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), + ('bookmarks', '0014_alter_bookmark_unread'), + ] + + operations = [ + migrations.CreateModel( + name='FeedToken', + fields=[ + ('key', models.CharField(max_length=40, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feed_token', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/bookmarks/models.py b/bookmarks/models.py index 7269845..eb8f8df 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -1,3 +1,5 @@ +import binascii +import os from typing import List from django import forms @@ -167,3 +169,27 @@ class Toast(models.Model): message = models.TextField() acknowledged = models.BooleanField(default=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + + +class FeedToken(models.Model): + """ + Adapted from authtoken.models.Token + """ + key = models.CharField(max_length=40, primary_key=True) + user = models.OneToOneField(get_user_model(), + related_name='feed_token', + on_delete=models.CASCADE, + ) + created = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + if not self.key: + self.key = self.generate_key() + return super().save(*args, **kwargs) + + @classmethod + def generate_key(cls): + return binascii.hexlify(os.urandom(20)).decode() + + def __str__(self): + return self.key diff --git a/bookmarks/templates/settings/integrations.html b/bookmarks/templates/settings/integrations.html index 68f581c..172feb2 100644 --- a/bookmarks/templates/settings/integrations.html +++ b/bookmarks/templates/settings/integrations.html @@ -38,9 +38,31 @@ -

Please treat this token as you would any other credential. Any party with access to this - token can access and manage all your bookmarks.

-

If you think that a token was compromised you can revoke (delete) it in the admin panel. After deleting the token, a new one will be generated when you reload this settings page.

+

+ Please treat this token as you would any other credential. + Any party with access to this token can access and manage all your bookmarks. + If you think that a token was compromised you can revoke (delete) it in the admin panel. + After deleting the token, a new one will be generated when you reload this settings page. +

+ + +
+

RSS Feeds

+

The following URLs provide RSS feeds for your bookmarks:

+ +

+ All URLs support appending a q URL parameter for specifying a search query. + You can get an example by doing a search in the bookmarks view and then copying the parameter from the URL. +

+

+ Please note that these URLs include an authentication token that should be treated like any other credential. + Any party with access to these URLs can read all your bookmarks. + If you think that a URL was compromised you can delete the feed token for your user in the admin panel. + After deleting the feed token, new URLs will be generated when you reload this settings page. +

{% endblock %} diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py index f7b1f41..2309dc1 100644 --- a/bookmarks/tests/helpers.py +++ b/bookmarks/tests/helpers.py @@ -1,7 +1,6 @@ import random import logging -from dataclasses import dataclass -from typing import Optional, List +from typing import List from django.contrib.auth.models import User from django.utils import timezone @@ -33,6 +32,8 @@ class BookmarkFactoryMixin: website_description: str = '', web_archive_snapshot_url: str = '', ): + if not title: + title = get_random_string(length=32) if tags is None: tags = [] if user is None: diff --git a/bookmarks/tests/test_feeds.py b/bookmarks/tests/test_feeds.py new file mode 100644 index 0000000..51ef971 --- /dev/null +++ b/bookmarks/tests/test_feeds.py @@ -0,0 +1,191 @@ +import datetime +import email +import urllib.parse + +from django.test import TestCase +from django.urls import reverse + +from bookmarks.tests.helpers import BookmarkFactoryMixin +from bookmarks.models import FeedToken, User + + +def rfc2822_date(date): + if not isinstance(date, datetime.datetime): + date = datetime.datetime.combine(date, datetime.time()) + return email.utils.format_datetime(date) + + +class FeedsTestCase(TestCase, BookmarkFactoryMixin): + + def setUp(self) -> None: + user = self.get_or_create_test_user() + self.client.force_login(user) + self.token = FeedToken.objects.get_or_create(user=user)[0] + + def test_all_returns_404_for_unknown_feed_token(self): + response = self.client.get(reverse('bookmarks:feeds.all', args=['foo'])) + + self.assertEqual(response.status_code, 404) + + def test_all_metadata(self): + feed_url = reverse('bookmarks:feeds.all', args=[self.token.key]) + response = self.client.get(feed_url) + self.assertEqual(response.status_code, 200) + + self.assertContains(response, 'All bookmarks') + self.assertContains(response, 'All bookmarks') + self.assertContains(response, f'http://testserver{feed_url}') + self.assertContains(response, f'') + + def test_all_returns_all_unarchived_bookmarks(self): + bookmarks = [ + self.setup_bookmark(), + self.setup_bookmark(), + self.setup_bookmark(unread=True), + ] + self.setup_bookmark(is_archived=True) + self.setup_bookmark(is_archived=True) + self.setup_bookmark(is_archived=True) + + response = self.client.get(reverse('bookmarks:feeds.all', args=[self.token.key])) + self.assertEqual(response.status_code, 200) + + self.assertContains(response, '', count=len(bookmarks)) + + for bookmark in bookmarks: + expected_item = '' \ + f'{bookmark.resolved_title}' \ + f'{bookmark.url}' \ + f'{bookmark.resolved_description}' \ + f'{rfc2822_date(bookmark.date_added)}' \ + f'{bookmark.url}' \ + '' + self.assertContains(response, expected_item, count=1) + + def test_all_with_query(self): + tag1 = self.setup_tag() + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark(tags=[tag1]) + bookmark3 = self.setup_bookmark(tags=[tag1]) + + self.setup_bookmark() + self.setup_bookmark() + self.setup_bookmark() + + feed_url = reverse('bookmarks:feeds.all', args=[self.token.key]) + + url = feed_url + f'?q={bookmark1.title}' + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '', count=1) + self.assertContains(response, f'{bookmark1.url}', count=1) + + url = feed_url + '?q=' + urllib.parse.quote('#' + tag1.name) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '', count=2) + self.assertContains(response, f'{bookmark2.url}', count=1) + self.assertContains(response, f'{bookmark3.url}', count=1) + + url = feed_url + '?q=' + urllib.parse.quote(f'#{tag1.name} {bookmark2.title}') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '', count=1) + self.assertContains(response, f'{bookmark2.url}', count=1) + + def test_all_returns_only_user_owned_bookmarks(self): + other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') + self.setup_bookmark(unread=True, user=other_user) + self.setup_bookmark(unread=True, user=other_user) + self.setup_bookmark(unread=True, user=other_user) + + response = self.client.get(reverse('bookmarks:feeds.all', args=[self.token.key])) + self.assertEqual(response.status_code, 200) + + self.assertContains(response, '', count=0) + + def test_unread_returns_404_for_unknown_feed_token(self): + response = self.client.get(reverse('bookmarks:feeds.unread', args=['foo'])) + + self.assertEqual(response.status_code, 404) + + def test_unread_metadata(self): + feed_url = reverse('bookmarks:feeds.unread', args=[self.token.key]) + response = self.client.get(feed_url) + self.assertEqual(response.status_code, 200) + + self.assertContains(response, 'Unread bookmarks') + self.assertContains(response, 'All unread bookmarks') + self.assertContains(response, f'http://testserver{feed_url}') + self.assertContains(response, f'') + + def test_unread_returns_unread_and_unarchived_bookmarks(self): + self.setup_bookmark(unread=False) + self.setup_bookmark(unread=False) + self.setup_bookmark(unread=False) + self.setup_bookmark(unread=True, is_archived=True) + self.setup_bookmark(unread=True, is_archived=True) + self.setup_bookmark(unread=False, is_archived=True) + + unread_bookmarks = [ + self.setup_bookmark(unread=True), + self.setup_bookmark(unread=True), + self.setup_bookmark(unread=True), + ] + + response = self.client.get(reverse('bookmarks:feeds.unread', args=[self.token.key])) + self.assertEqual(response.status_code, 200) + + self.assertContains(response, '', count=len(unread_bookmarks)) + + for bookmark in unread_bookmarks: + expected_item = '' \ + f'{bookmark.resolved_title}' \ + f'{bookmark.url}' \ + f'{bookmark.resolved_description}' \ + f'{rfc2822_date(bookmark.date_added)}' \ + f'{bookmark.url}' \ + '' + self.assertContains(response, expected_item, count=1) + + def test_unread_with_query(self): + tag1 = self.setup_tag() + bookmark1 = self.setup_bookmark(unread=True) + bookmark2 = self.setup_bookmark(unread=True, tags=[tag1]) + bookmark3 = self.setup_bookmark(unread=True, tags=[tag1]) + + self.setup_bookmark(unread=True) + self.setup_bookmark(unread=True) + self.setup_bookmark(unread=True) + + feed_url = reverse('bookmarks:feeds.all', args=[self.token.key]) + + url = feed_url + f'?q={bookmark1.title}' + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '', count=1) + self.assertContains(response, f'{bookmark1.url}', count=1) + + url = feed_url + '?q=' + urllib.parse.quote('#' + tag1.name) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '', count=2) + self.assertContains(response, f'{bookmark2.url}', count=1) + self.assertContains(response, f'{bookmark3.url}', count=1) + + url = feed_url + '?q=' + urllib.parse.quote(f'#{tag1.name} {bookmark2.title}') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '', count=1) + self.assertContains(response, f'{bookmark2.url}', count=1) + + def test_unread_returns_only_user_owned_bookmarks(self): + other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') + self.setup_bookmark(unread=True, user=other_user) + self.setup_bookmark(unread=True, user=other_user) + self.setup_bookmark(unread=True, user=other_user) + + response = self.client.get(reverse('bookmarks:feeds.unread', args=[self.token.key])) + self.assertEqual(response.status_code, 200) + + self.assertContains(response, '', count=0) diff --git a/bookmarks/tests/test_settings_integrations_view.py b/bookmarks/tests/test_settings_integrations_view.py index 418e8bf..be1922e 100644 --- a/bookmarks/tests/test_settings_integrations_view.py +++ b/bookmarks/tests/test_settings_integrations_view.py @@ -3,6 +3,7 @@ from django.urls import reverse from rest_framework.authtoken.models import Token from bookmarks.tests.helpers import BookmarkFactoryMixin +from bookmarks.models import FeedToken class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin): @@ -38,3 +39,28 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin): self.client.get(reverse('bookmarks:settings.integrations')) self.assertEqual(Token.objects.count(), 1) + + def test_should_generate_feed_token_if_not_exists(self): + self.assertEqual(FeedToken.objects.count(), 0) + + self.client.get(reverse('bookmarks:settings.integrations')) + + self.assertEqual(FeedToken.objects.count(), 1) + token = FeedToken.objects.first() + self.assertEqual(token.user, self.user) + + def test_should_not_generate_feed_token_if_exists(self): + FeedToken.objects.get_or_create(user=self.user) + self.assertEqual(FeedToken.objects.count(), 1) + + self.client.get(reverse('bookmarks:settings.integrations')) + + self.assertEqual(FeedToken.objects.count(), 1) + + def test_should_display_feed_urls(self): + response = self.client.get(reverse('bookmarks:settings.integrations')) + html = response.content.decode() + + token = FeedToken.objects.first() + self.assertInHTML(f'All bookmarks', html) + self.assertInHTML(f'Unread bookmarks', html) diff --git a/bookmarks/urls.py b/bookmarks/urls.py index c6fc453..a0627a2 100644 --- a/bookmarks/urls.py +++ b/bookmarks/urls.py @@ -4,6 +4,7 @@ from django.views.generic import RedirectView from bookmarks.api.routes import router from bookmarks import views +from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed app_name = 'bookmarks' urlpatterns = [ @@ -25,5 +26,8 @@ urlpatterns = [ # Toasts path('toasts/acknowledge', views.toasts.acknowledge, name='toasts.acknowledge'), # API - path('api/', include(router.urls), name='api') + path('api/', include(router.urls), name='api'), + # Feeds + path('feeds//all', AllBookmarksFeed(), name='feeds.all'), + path('feeds//unread', UnreadBookmarksFeed(), name='feeds.unread'), ] diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py index e5dd19c..c2ef3b8 100644 --- a/bookmarks/views/settings.py +++ b/bookmarks/views/settings.py @@ -10,7 +10,7 @@ from django.shortcuts import render from django.urls import reverse from rest_framework.authtoken.models import Token -from bookmarks.models import UserProfileForm +from bookmarks.models import UserProfileForm, FeedToken from bookmarks.queries import query_bookmarks from bookmarks.services import exporter from bookmarks.services import importer @@ -75,9 +75,14 @@ def get_ttl_hash(seconds=3600): def integrations(request): application_url = request.build_absolute_uri("/bookmarks/new") api_token = Token.objects.get_or_create(user=request.user)[0] + feed_token = FeedToken.objects.get_or_create(user=request.user)[0] + all_feed_url = request.build_absolute_uri(reverse('bookmarks:feeds.all', args=[feed_token.key])) + unread_feed_url = request.build_absolute_uri(reverse('bookmarks:feeds.unread', args=[feed_token.key])) return render(request, 'settings/integrations.html', { 'application_url': application_url, - 'api_token': api_token.key + 'api_token': api_token.key, + 'all_feed_url': all_feed_url, + 'unread_feed_url': unread_feed_url, })