Add RSS feeds (#305)

* 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 <sascha.issbruecker@gmail.com>
This commit is contained in:
Sascha Ißbrücker
2022-07-23 23:20:27 +02:00
committed by GitHub
parent 13ff9ac4f8
commit 54ce6d5fe6
10 changed files with 371 additions and 9 deletions

View File

@@ -9,7 +9,7 @@ from django.utils.translation import ngettext, gettext
from rest_framework.authtoken.admin import TokenAdmin from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import TokenProxy 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 from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -121,11 +121,18 @@ class AdminToast(admin.ModelAdmin):
list_filter = ('owner__username',) 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 = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark) linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(Tag, AdminTag) linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, AdminCustomUser) linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(TokenProxy, TokenAdmin) linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast) linkding_admin_site.register(Toast, AdminToast)
linkding_admin_site.register(FeedToken, AdminFeedToken)
linkding_admin_site.register(Task, TaskAdmin) linkding_admin_site.register(Task, TaskAdmin)
linkding_admin_site.register(CompletedTask, CompletedTaskAdmin) linkding_admin_site.register(CompletedTask, CompletedTaskAdmin)

56
bookmarks/feeds.py Normal file
View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import binascii
import os
from typing import List from typing import List
from django import forms from django import forms
@@ -167,3 +169,27 @@ class Toast(models.Model):
message = models.TextField() message = models.TextField()
acknowledged = models.BooleanField(default=False) acknowledged = models.BooleanField(default=False)
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) 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

View File

@@ -38,9 +38,31 @@
</div> </div>
</div> </div>
</div> </div>
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this <p>
token can access and manage all your bookmarks.</p> <strong>Please treat this token as you would any other credential.</strong>
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p> 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 <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
After deleting the token, a new one will be generated when you reload this settings page.
</p>
</section>
<section class="content-area">
<h2>RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul>
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
</ul>
<p>
All URLs support appending a <code>q</code> 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.
</p>
<p>
<strong>Please note that these URLs include an authentication token that should be treated like any other credential.</strong>
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 <a href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
After deleting the feed token, new URLs will be generated when you reload this settings page.
</p>
</section> </section>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,6 @@
import random import random
import logging import logging
from dataclasses import dataclass from typing import List
from typing import Optional, List
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone from django.utils import timezone
@@ -33,6 +32,8 @@ class BookmarkFactoryMixin:
website_description: str = '', website_description: str = '',
web_archive_snapshot_url: str = '', web_archive_snapshot_url: str = '',
): ):
if not title:
title = get_random_string(length=32)
if tags is None: if tags is None:
tags = [] tags = []
if user is None: if user is None:

View File

@@ -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, '<title>All bookmarks</title>')
self.assertContains(response, '<description>All bookmarks</description>')
self.assertContains(response, f'<link>http://testserver{feed_url}</link>')
self.assertContains(response, f'<atom:link href="http://testserver{feed_url}" rel="self"></atom:link>')
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, '<item>', count=len(bookmarks))
for bookmark in bookmarks:
expected_item = '<item>' \
f'<title>{bookmark.resolved_title}</title>' \
f'<link>{bookmark.url}</link>' \
f'<description>{bookmark.resolved_description}</description>' \
f'<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>' \
f'<guid>{bookmark.url}</guid>' \
'</item>'
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, '<item>', count=1)
self.assertContains(response, f'<guid>{bookmark1.url}</guid>', 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, '<item>', count=2)
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', count=1)
self.assertContains(response, f'<guid>{bookmark3.url}</guid>', 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, '<item>', count=1)
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', 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, '<item>', 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, '<title>Unread bookmarks</title>')
self.assertContains(response, '<description>All unread bookmarks</description>')
self.assertContains(response, f'<link>http://testserver{feed_url}</link>')
self.assertContains(response, f'<atom:link href="http://testserver{feed_url}" rel="self"></atom:link>')
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, '<item>', count=len(unread_bookmarks))
for bookmark in unread_bookmarks:
expected_item = '<item>' \
f'<title>{bookmark.resolved_title}</title>' \
f'<link>{bookmark.url}</link>' \
f'<description>{bookmark.resolved_description}</description>' \
f'<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>' \
f'<guid>{bookmark.url}</guid>' \
'</item>'
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, '<item>', count=1)
self.assertContains(response, f'<guid>{bookmark1.url}</guid>', 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, '<item>', count=2)
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', count=1)
self.assertContains(response, f'<guid>{bookmark3.url}</guid>', 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, '<item>', count=1)
self.assertContains(response, f'<guid>{bookmark2.url}</guid>', 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, '<item>', count=0)

View File

@@ -3,6 +3,7 @@ from django.urls import reverse
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import FeedToken
class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin): class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
@@ -38,3 +39,28 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
self.client.get(reverse('bookmarks:settings.integrations')) self.client.get(reverse('bookmarks:settings.integrations'))
self.assertEqual(Token.objects.count(), 1) 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'<a href="http://testserver/feeds/{token.key}/all">All bookmarks</a>', html)
self.assertInHTML(f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>', html)

View File

@@ -4,6 +4,7 @@ from django.views.generic import RedirectView
from bookmarks.api.routes import router from bookmarks.api.routes import router
from bookmarks import views from bookmarks import views
from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed
app_name = 'bookmarks' app_name = 'bookmarks'
urlpatterns = [ urlpatterns = [
@@ -25,5 +26,8 @@ urlpatterns = [
# Toasts # Toasts
path('toasts/acknowledge', views.toasts.acknowledge, name='toasts.acknowledge'), path('toasts/acknowledge', views.toasts.acknowledge, name='toasts.acknowledge'),
# API # API
path('api/', include(router.urls), name='api') path('api/', include(router.urls), name='api'),
# Feeds
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'),
] ]

View File

@@ -10,7 +10,7 @@ from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from rest_framework.authtoken.models import Token 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.queries import query_bookmarks
from bookmarks.services import exporter from bookmarks.services import exporter
from bookmarks.services import importer from bookmarks.services import importer
@@ -75,9 +75,14 @@ def get_ttl_hash(seconds=3600):
def integrations(request): def integrations(request):
application_url = request.build_absolute_uri("/bookmarks/new") application_url = request.build_absolute_uri("/bookmarks/new")
api_token = Token.objects.get_or_create(user=request.user)[0] 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', { return render(request, 'settings/integrations.html', {
'application_url': application_url, '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,
}) })