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,
})