Compare commits

...

10 Commits

Author SHA1 Message Date
Sascha Ißbrücker
b736464f3f Bump version 2024-09-10 21:39:48 +02:00
Sascha Ißbrücker
7572aa5bc9 Fix auto-tagging when URL includes port (#820) 2024-09-10 21:19:20 +02:00
Sascha Ißbrücker
cb0301fd9e Fix inconsistent tag order in bookmarks (#819) 2024-09-10 21:06:57 +02:00
Sascha Ißbrücker
b30486317d Allow pre-filling notes in new bookmark form (#812) 2024-08-31 23:20:44 +02:00
Sascha Ißbrücker
1c6e5902db Additional filter parameters for RSS feeds (#811) 2024-08-31 22:58:41 +02:00
Sascha Ißbrücker
20fe88dd57 Return bookmark tags in RSS feeds (#810) 2024-08-31 22:41:22 +02:00
Sascha Ißbrücker
aad62f61c9 Allow configuring guest user profile (#809) 2024-08-31 20:25:43 +02:00
Sascha Ißbrücker
79bf4b38c6 remove unused context processor 2024-08-31 19:10:42 +02:00
Sascha Ißbrücker
5eadb3ede3 Allow configuring landing page for unauthenticated users (#808)
* allow configuring landing page

* add tests
2024-08-31 15:39:22 +02:00
Sascha Ißbrücker
36749c398b Update CHANGELOG.md 2024-08-30 19:51:39 +02:00
30 changed files with 785 additions and 373 deletions

View File

@@ -1,5 +1,32 @@
# Changelog
## v1.31.1 (30/08/2024)
### What's Changed
* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763
* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772
* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800
* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788
* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765
* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701
* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804
* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805
* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795
* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775
* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769
* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762
### New Contributors
* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772
* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800
* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788
* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765
* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1
---
## v1.31.0 (16/06/2024)
### What's Changed

View File

@@ -1,3 +1,5 @@
import logging
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
@@ -19,6 +21,8 @@ from bookmarks.services.bookmarks import (
)
from bookmarks.services.website_loader import WebsiteMetadata
logger = logging.getLogger(__name__)
class BookmarkViewSet(
viewsets.GenericViewSet,
@@ -112,7 +116,13 @@ class BookmarkViewSet(
profile = request.user.profile
auto_tags = []
if profile.auto_tagging_rules:
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
try:
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
except Exception as e:
logger.error(
f"Failed to auto-tag bookmark. url={bookmark.url}",
exc_info=e,
)
return Response(
{

View File

@@ -18,19 +18,5 @@ def toasts(request):
}
def public_shares(request):
# Only check for public shares for anonymous users
if not request.user.is_authenticated:
query_set = queries.query_shared_bookmarks(
None, request.user_profile, BookmarkSearch(), True
)
has_public_shares = query_set.count() > 0
return {
"has_public_shares": has_public_shares,
}
return {}
def app_version(request):
return {"app_version": utils.app_version}

View File

@@ -2,7 +2,8 @@ import unicodedata
from dataclasses import dataclass
from django.contrib.syndication.views import Feed
from django.db.models import QuerySet
from django.db.models import QuerySet, prefetch_related_objects
from django.http import HttpRequest
from django.urls import reverse
from bookmarks import queries
@@ -11,6 +12,7 @@ from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
@dataclass
class FeedContext:
request: HttpRequest
feed_token: FeedToken | None
query_set: QuerySet[Bookmark]
@@ -26,13 +28,27 @@ def sanitize(text: str):
class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_bookmarks(
feed_token.user, feed_token.user.profile, search
def get_object(self, request, feed_key: str | None):
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
search = BookmarkSearch(
q=request.GET.get("q", ""),
unread=request.GET.get("unread", ""),
shared=request.GET.get("shared", ""),
)
return FeedContext(feed_token, query_set)
query_set = self.get_query_set(feed_token, search)
return FeedContext(request, feed_token, query_set)
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
raise NotImplementedError
def items(self, context: FeedContext):
limit = context.request.GET.get("limit", 100)
if limit:
data = context.query_set[: int(limit)]
else:
data = list(context.query_set)
prefetch_related_objects(data, "tags")
return data
def item_title(self, item: Bookmark):
return sanitize(item.resolved_title)
@@ -46,60 +62,56 @@ class BaseBookmarksFeed(Feed):
def item_pubdate(self, item: Bookmark):
return item.date_added
def item_categories(self, item: Bookmark):
return item.tag_names
class AllBookmarksFeed(BaseBookmarksFeed):
title = "All bookmarks"
description = "All bookmarks"
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
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 get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_bookmarks(
feed_token.user, feed_token.user.profile, search
).filter(unread=True)
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)
class SharedBookmarksFeed(BaseBookmarksFeed):
title = "Shared bookmarks"
description = "All shared bookmarks"
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_shared_bookmarks(
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_shared_bookmarks(
None, feed_token.user.profile, search, False
)
return FeedContext(feed_token, query_set)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
title = "Public shared bookmarks"
description = "All public shared bookmarks"
def get_object(self, request):
search = BookmarkSearch(q=request.GET.get("q", ""))
default_profile = UserProfile()
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
return FeedContext(None, query_set)
return super().get_object(request, None)
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_shared_bookmarks(None, UserProfile(), search, True)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.public_shared")
def items(self, context: FeedContext):
return context.query_set

View File

@@ -1,13 +1,17 @@
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware
from bookmarks.models import UserProfile
from bookmarks.models import UserProfile, GlobalSettings
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
standard_profile = UserProfile()
standard_profile.enable_favicons = True
class UserProfileMiddleware:
def __init__(self, get_response):
self.get_response = get_response
@@ -16,8 +20,16 @@ class UserProfileMiddleware:
if request.user.is_authenticated:
request.user_profile = request.user.profile
else:
request.user_profile = UserProfile()
request.user_profile.enable_favicons = True
# check if a custom profile for guests exists, otherwise use standard profile
guest_profile = None
try:
global_settings = GlobalSettings.get()
if global_settings.guest_profile_user:
guest_profile = global_settings.guest_profile_user.profile
except:
pass
request.user_profile = guest_profile or standard_profile
response = self.get_response(request)

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.0.8 on 2024-08-31 12:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0036_userprofile_auto_tagging_rules"),
]
operations = [
migrations.CreateModel(
name="GlobalSettings",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"landing_page",
models.CharField(
choices=[
("login", "Login"),
("shared_bookmarks", "Shared Bookmarks"),
],
default="login",
max_length=50,
),
),
],
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.0.8 on 2024-08-31 17:54
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0037_globalsettings"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="globalsettings",
name="guest_profile_user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -84,7 +84,8 @@ class Bookmark(models.Model):
@property
def tag_names(self):
return [tag.name for tag in self.tags.all()]
names = [tag.name for tag in self.tags.all()]
return sorted(names)
def __str__(self):
return self.resolved_title + " (" + self.url[:30] + "...)"
@@ -169,7 +170,9 @@ class BookmarkForm(forms.ModelForm):
@property
def has_notes(self):
return self.instance and self.instance.notes
return self.initial.get("notes", None) or (
self.instance and self.instance.notes
)
class BookmarkSearch:
@@ -492,3 +495,45 @@ class FeedToken(models.Model):
def __str__(self):
return self.key
class GlobalSettings(models.Model):
LANDING_PAGE_LOGIN = "login"
LANDING_PAGE_SHARED_BOOKMARKS = "shared_bookmarks"
LANDING_PAGE_CHOICES = [
(LANDING_PAGE_LOGIN, "Login"),
(LANDING_PAGE_SHARED_BOOKMARKS, "Shared Bookmarks"),
]
landing_page = models.CharField(
max_length=50,
choices=LANDING_PAGE_CHOICES,
blank=False,
default=LANDING_PAGE_LOGIN,
)
guest_profile_user = models.ForeignKey(
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
)
@classmethod
def get(cls):
instance = GlobalSettings.objects.first()
if not instance:
instance = GlobalSettings()
instance.save()
return instance
def save(self, *args, **kwargs):
if not self.pk and GlobalSettings.objects.exists():
raise Exception("There is already one instance of GlobalSettings")
return super(GlobalSettings, self).save(*args, **kwargs)
class GlobalSettingsForm(forms.ModelForm):
class Meta:
model = GlobalSettings
fields = ["landing_page", "guest_profile_user"]
def __init__(self, *args, **kwargs):
super(GlobalSettingsForm, self).__init__(*args, **kwargs)
self.fields["guest_profile_user"].empty_label = "Standard profile"

View File

@@ -16,27 +16,21 @@ def get_tags(script: str, url: str):
if len(parts) < 2:
continue
domain_pattern = re.sub("^https?://", "", parts[0])
path_pattern = None
qs_pattern = None
# to parse a host name from the pattern URL, ensure it has a scheme
pattern_url = "//" + re.sub("^https?://", "", parts[0])
parsed_pattern = urlparse(pattern_url)
if "/" in domain_pattern:
i = domain_pattern.index("/")
path_pattern = domain_pattern[i:]
domain_pattern = domain_pattern[:i]
if path_pattern and "?" in path_pattern:
i = path_pattern.index("?")
qs_pattern = path_pattern[i + 1 :]
path_pattern = path_pattern[:i]
if not _domains_matches(domain_pattern, parsed_url.netloc):
if not _domains_matches(parsed_pattern.hostname, parsed_url.hostname):
continue
if path_pattern and not _path_matches(path_pattern, parsed_url.path):
if parsed_pattern.path and not _path_matches(
parsed_pattern.path, parsed_url.path
):
continue
if qs_pattern and not _qs_matches(qs_pattern, parsed_url.query):
if parsed_pattern.query and not _qs_matches(
parsed_pattern.query, parsed_url.query
):
continue
for tag in parts[1:]:

View File

@@ -245,12 +245,18 @@ def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string)
if user.profile.auto_tagging_rules:
auto_tag_names = auto_tagging.get_tags(
user.profile.auto_tagging_rules, bookmark.url
)
for auto_tag_name in auto_tag_names:
if auto_tag_name not in tag_names:
tag_names.append(auto_tag_name)
try:
auto_tag_names = auto_tagging.get_tags(
user.profile.auto_tagging_rules, bookmark.url
)
for auto_tag_name in auto_tag_names:
if auto_tag_name not in tag_names:
tag_names.append(auto_tag_name)
except Exception as e:
logger.error(
f"Failed to auto-tag bookmark. url={bookmark.url}",
exc_info=e,
)
tags = get_or_create_tags(tag_names, user)
bookmark.tags.set(tags)

View File

@@ -114,16 +114,16 @@
</div>
{% endif %}
<div class="d-flex justify-between">
<a href="{% url 'bookmarks:index' %}" class="d-flex align-center">
<a href="{% url 'bookmarks:root' %}" class="d-flex align-center">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>LINKDING</h1>
</a>
{% if request.user.is_authenticated %}
{# Only show nav items menu when logged in #}
{% include 'bookmarks/nav_menu.html' %}
{% elif has_public_shares %}
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
{% else %}
{# Otherwise show login link #}
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
{% endif %}
</div>
</header>

View File

@@ -7,13 +7,14 @@
{% include 'settings/nav.html' %}
{# Profile section #}
{% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
<section class="content-area">
{% if success_message %}
<div class="toast toast-success mb-4">{{ success_message }}</div>
{% endif %}
{% if error_message %}
<div class="toast toast-error mb-4">{{ error_message }}</div>
{% endif %}
<h2>Profile</h2>
<p>
<a href="{% url 'change_password' %}">Change password</a>
@@ -238,6 +239,37 @@ reddit.com/r/Music music reddit</pre>
</form>
</section>
{# Global settings section #}
{% if global_settings_form %}
<section class="content-area">
<h2>Global settings</h2>
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
{% csrf_token %}
<div class="form-group">
<label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label>
{{ global_settings_form.landing_page|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
The page that unauthenticated users are redirected to when accessing the root URL.
</div>
</div>
<div class="form-group">
<label for="{{ global_settings_form.guest_profile_user.id_for_label }}" class="form-label">Guest user
profile</label>
{{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }}
<div class="form-input-hint">
The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks
are displayed regarding theme, bookmark list settings, etc. You can either use your own profile or create
a dedicated user for this purpose. By default, a standard profile with fixed settings is used.
</div>
</div>
<div class="form-group">
<input type="submit" name="update_global_settings" value="Save" class="btn btn-primary mt-2">
</div>
</form>
</section>
{% endif %}
{# Import section #}
<section class="content-area">
<h2>Import</h2>

View File

@@ -1,70 +1,90 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
<div class="settings-page">
{% include 'settings/nav.html' %}
{% include 'settings/nav.html' %}
<section class="content-area">
<h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p>
<ul>
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
</ul>
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
<h2>Bookmarklet</h2>
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application
first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browsers toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
<section class="content-area">
<h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The
extension is available in the official extension stores for:</p>
<ul>
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
target="_blank">Chrome</a></li>
</ul>
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a>
as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
<h2>Bookmarklet</h2>
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
application first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browser's toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
<section class="content-area">
<h2>REST API</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column width-50 width-md-100">
<input class="form-input" value="{{ api_token }}" readonly>
</div>
</div>
</div>
<p>
<strong>Please treat this token as you would any other credential.</strong>
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>REST API</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column width-50 width-md-100">
<input class="form-input" value="{{ api_token }}" readonly>
</div>
</div>
</div>
<p>
<strong>Please treat this token as you would any other credential.</strong>
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 style="list-style-position: outside;">
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></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>
</div>
<section class="content-area">
<h2>RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul style="list-style-position: outside;">
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span>
</li>
</ul>
<p>
All URLs support the following URL parameters:
</p>
<ul style="list-style-position: outside;">
<li>A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By
default, only the latest 100 matching bookmarks are included.
</li>
<li>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.
</li>
<li>An <code>unread</code> parameter for filtering for unread or read bookmarks. Use <code>yes</code> for unread
bookmarks and <code>no</code> for read bookmarks.
</li>
<li>A <code>shared</code> parameter for filtering for shared or unshared bookmarks. Use <code>yes</code> for
shared bookmarks and <code>no</code> for unshared bookmarks.
</li>
</ul>
<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>
</div>
{% endblock %}

View File

@@ -24,6 +24,11 @@ class BookmarkFactoryMixin:
return self.user
def setup_superuser(self):
return User.objects.create_superuser(
"superuser", "superuser@example.com", "password123"
)
def setup_bookmark(
self,
is_archived: bool = False,

View File

@@ -1,29 +0,0 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
def assertSharedBookmarksLinkCount(self, response, count):
url = reverse("bookmarks:shared")
self.assertContains(
response,
f'<a href="{url}" class="btn btn-link">Shared bookmarks</a>',
count=count,
)
def test_publicly_shared_bookmarks_link(self):
# should not render link if no public shares exist
user = self.setup_user(enable_sharing=True)
self.setup_bookmark(user=user, shared=True)
response = self.client.get(reverse("login"))
self.assertSharedBookmarksLinkCount(response, 0)
# should render link if public shares exist
user.profile.enable_public_sharing = True
user.profile.save()
response = self.client.get(reverse("login"))
self.assertSharedBookmarksLinkCount(response, 1)

View File

@@ -12,7 +12,18 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example"]))
self.assertEqual(tags, {"example"})
def test_auto_tag_by_domain_works_with_port(self):
script = """
example.com example
test.com test
"""
url = "https://example.com:8080/"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, {"example"})
def test_auto_tag_by_domain_ignores_case(self):
script = """
@@ -22,7 +33,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example"]))
self.assertEqual(tags, {"example"})
def test_auto_tag_by_domain_should_add_all_tags(self):
script = """
@@ -32,7 +43,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one", "two", "three"]))
self.assertEqual(tags, {"one", "two", "three"})
def test_auto_tag_by_domain_work_with_idn_domains(self):
script = """
@@ -42,7 +53,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"]))
self.assertEqual(tags, {"tag1"})
script = """
xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1
@@ -51,7 +62,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"]))
self.assertEqual(tags, {"tag1"})
def test_auto_tag_by_domain_and_path(self):
script = """
@@ -63,7 +74,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"]))
self.assertEqual(tags, {"one"})
def test_auto_tag_by_domain_and_path_ignores_case(self):
script = """
@@ -73,7 +84,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"]))
self.assertEqual(tags, {"one"})
def test_auto_tag_by_domain_and_path_matches_path_ltr(self):
script = """
@@ -85,7 +96,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["one"]))
self.assertEqual(tags, {"one"})
def test_auto_tag_by_domain_ignores_domain_in_path(self):
script = """
@@ -107,7 +118,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["example", "test"]))
self.assertEqual(tags, {"example", "test"})
def test_auto_tag_by_domain_matches_domain_rtl(self):
script = """
@@ -128,7 +139,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["https", "http"]))
self.assertEqual(tags, {"https", "http"})
def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):
script = """
@@ -154,7 +165,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1", "tag2", "tag5", "tag6", "tag7"]))
self.assertEqual(tags, {"tag1", "tag2", "tag5", "tag6", "tag7"})
def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):
script = """
@@ -165,7 +176,7 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1"]))
self.assertEqual(tags, {"tag1"})
def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):
script = """
@@ -176,4 +187,4 @@ class AutoTaggingTestCase(TestCase):
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set(["tag1", "tag2"]))
self.assertEqual(tags, {"tag1", "tag2"})

View File

@@ -100,6 +100,29 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html,
)
def test_should_prefill_notes_from_url_parameter(self):
response = self.client.get(
reverse("bookmarks:new")
+ "?notes=%2A%2AFind%2A%2A%20more%20info%20%5Bhere%5D%28http%3A%2F%2Fexample.com%29"
)
html = response.content.decode()
self.assertInHTML(
"""
<details class="notes" open="">
<summary>
<span class="form-label d-inline-block">Notes</span>
</summary>
<label for="id_notes" class="text-assistive">Notes</label>
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">**Find** more info [here](http://example.com)</textarea>
<div class="form-input-hint">
Additional notes, supports Markdown.
</div>
</details>
""",
html,
)
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
response = self.client.get(reverse("bookmarks:new") + "?auto_close")
html = response.content.decode()

View File

@@ -457,6 +457,22 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
style = bookmark_list["style"]
self.assertIn("--ld-bookmark-description-max-lines:3;", style)
def test_bookmark_tag_ordering(self):
bookmark = self.setup_bookmark()
tag3 = self.setup_tag(name="tag3")
tag1 = self.setup_tag(name="tag1")
tag2 = self.setup_tag(name="tag2")
bookmark.tags.add(tag3, tag1, tag2)
html = self.render_template()
soup = self.make_soup(html)
tags = soup.select_one(".tags")
tag_links = tags.find_all("a")
self.assertEqual(len(tag_links), 3)
self.assertEqual(tag_links[0].text, "#tag1")
self.assertEqual(tag_links[1].text, "#tag2")
self.assertEqual(tag_links[2].text, "#tag3")
def test_should_render_web_archive_link_with_absolute_date_setting(self):
bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,

View File

@@ -23,6 +23,26 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.client.force_login(user)
self.token = FeedToken.objects.get_or_create(user=user)[0]
def assertFeedItems(self, response, bookmarks):
self.assertContains(response, "<item>", count=len(bookmarks))
for bookmark in bookmarks:
categories = []
for tag in bookmark.tag_names:
categories.append(f"<category>{tag}</category>")
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>"
f"{''.join(categories)}"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_all_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse("bookmarks:feeds.all", args=["foo"]))
@@ -54,51 +74,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
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)
self.assertFeedItems(response, bookmarks)
def test_all_returns_only_user_owned_bookmarks(self):
other_user = User.objects.create_user(
@@ -115,23 +91,6 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertContains(response, "<item>", count=0)
def test_strip_control_characters(self):
self.setup_bookmark(
title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description"
)
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<title>test\n\r\ttitle</title>", count=1)
self.assertContains(
response, f"<description>test\n\r\tdescription</description>", count=1
)
def test_sanitize_with_none_text(self):
self.assertEqual("", sanitize(None))
def test_unread_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse("bookmarks:feeds.unread", args=["foo"]))
@@ -169,51 +128,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
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.unread", 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)
self.assertFeedItems(response, unread_bookmarks)
def test_unread_returns_only_user_owned_bookmarks(self):
other_user = User.objects.create_user(
@@ -265,53 +180,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
reverse("bookmarks:feeds.shared", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=len(shared_bookmarks))
for bookmark in shared_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_shared_with_query(self):
user = self.setup_user(enable_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", 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)
self.assertFeedItems(response, shared_bookmarks)
def test_public_shared_does_not_require_auth(self):
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
@@ -351,34 +220,19 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, public_shared_bookmarks)
self.assertContains(response, "<item>", count=len(public_shared_bookmarks))
def test_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark(tags=[tag1])
bookmark3 = self.setup_bookmark(tags=[tag1])
for bookmark in public_shared_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)
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
def test_public_shared_with_query(self):
user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
@@ -398,3 +252,117 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
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_parameter(self):
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=False),
self.setup_bookmark(unread=False),
self.setup_bookmark(unread=False),
self.setup_bookmark(unread=False),
# without unread parameter
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=6)
# with unread=yes
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?unread=yes"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
# with unread=no
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?unread=no"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=4)
def test_shared_parameter(self):
self.setup_bookmark(shared=True)
self.setup_bookmark(shared=True)
self.setup_bookmark(shared=False)
self.setup_bookmark(shared=False)
self.setup_bookmark(shared=False)
self.setup_bookmark(shared=False)
# without shared parameter
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=6)
# with shared=yes
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?shared=yes"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
# with shared=no
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?shared=no"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=4)
def test_with_tags(self):
bookmarks = [
self.setup_bookmark(description="test description"),
self.setup_bookmark(
description="test description",
tags=[self.setup_tag(), self.setup_tag()],
),
]
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, bookmarks)
def test_with_limit(self):
self.setup_numbered_bookmarks(200)
# without limit - defaults to 100
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=100)
# with increased limit
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?limit=200"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=200)
# with decreased limit
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?limit=5"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=5)
def test_strip_control_characters(self):
self.setup_bookmark(
title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description"
)
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<title>test\n\r\ttitle</title>", count=1)
self.assertContains(
response, f"<description>test\n\r\tdescription</description>", count=1
)
def test_sanitize_with_none_text(self):
self.assertEqual("", sanitize(None))

View File

@@ -0,0 +1,40 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
class RootViewTestCase(TestCase, BookmarkFactoryMixin):
def test_unauthenticated_user_redirect_to_login_by_default(self):
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("login"))
def test_unauthenticated_redirect_to_shared_bookmarks_if_configured_in_global_settings(
self,
):
settings = GlobalSettings.get()
settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS
settings.save()
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:shared"))
def test_authenticated_user_always_redirected_to_bookmarks(self):
self.client.force_login(self.get_or_create_test_user())
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:index"))
settings = GlobalSettings.get()
settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS
settings.save()
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:index"))
settings.landing_page = GlobalSettings.LANDING_PAGE_LOGIN
settings.save()
response = self.client.get(reverse("bookmarks:root"))
self.assertRedirects(response, reverse("bookmarks:index"))

View File

@@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from requests import RequestException
from bookmarks.models import UserProfile
from bookmarks.models import UserProfile, GlobalSettings
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info
@@ -465,3 +465,82 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertSuccessMessage(
html, "Queued 5 missing snapshots. This may take a while...", count=0
)
def test_update_global_settings(self):
superuser = self.setup_superuser()
self.client.force_login(superuser)
selectable_user = self.setup_user()
# Update global settings
form_data = {
"update_global_settings": "",
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
"guest_profile_user": selectable_user.id,
}
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
self.assertEqual(response.status_code, 200)
self.assertSuccessMessage(response.content.decode(), "Global settings updated")
global_settings = GlobalSettings.get()
self.assertEqual(global_settings.landing_page, form_data["landing_page"])
self.assertEqual(global_settings.guest_profile_user, selectable_user)
# Revert settings
form_data = {
"update_global_settings": "",
"landing_page": GlobalSettings.LANDING_PAGE_LOGIN,
"guest_profile_user": "",
}
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
self.assertEqual(response.status_code, 200)
self.assertSuccessMessage(response.content.decode(), "Global settings updated")
global_settings = GlobalSettings.get()
global_settings.refresh_from_db()
self.assertEqual(global_settings.landing_page, form_data["landing_page"])
self.assertIsNone(global_settings.guest_profile_user)
def test_update_global_settings_should_not_be_called_without_respective_form_action(
self,
):
superuser = self.setup_superuser()
self.client.force_login(superuser)
form_data = {
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
}
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
self.assertEqual(response.status_code, 200)
self.assertSuccessMessage(
response.content.decode(), "Global settings updated", count=0
)
def test_update_global_settings_checks_for_superuser(self):
form_data = {
"update_global_settings": "",
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
}
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
self.assertEqual(response.status_code, 403)
def test_global_settings_only_visible_for_superuser(self):
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
self.assertInHTML(
"<h2>Global settings</h2>",
html,
count=0,
)
superuser = self.setup_superuser()
self.client.force_login(superuser)
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
self.assertInHTML(
"<h2>Global settings</h2>",
html,
count=1,
)

View File

@@ -0,0 +1,47 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import UserProfile, GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.middlewares import standard_profile
class UserProfileMiddlewareTestCase(TestCase, BookmarkFactoryMixin):
def test_unauthenticated_user_should_use_standard_profile_by_default(self):
response = self.client.get(reverse("login"))
self.assertEqual(standard_profile, response.wsgi_request.user_profile)
def test_unauthenticated_user_should_use_custom_configured_profile(self):
guest_user = self.setup_user()
guest_user_profile = guest_user.profile
guest_user_profile.theme = UserProfile.THEME_DARK
guest_user_profile.save()
global_settings = GlobalSettings.get()
global_settings.guest_profile_user = guest_user
global_settings.save()
response = self.client.get(reverse("login"))
self.assertEqual(guest_user_profile, response.wsgi_request.user_profile)
def test_authenticated_user_should_use_own_profile(self):
guest_user = self.setup_user()
guest_user_profile = guest_user.profile
guest_user_profile.theme = UserProfile.THEME_DARK
guest_user_profile.save()
global_settings = GlobalSettings.get()
global_settings.guest_profile_user = guest_user
global_settings.save()
user = self.get_or_create_test_user()
user_profile = user.profile
user_profile.theme = UserProfile.THEME_LIGHT
user_profile.save()
self.client.force_login(user)
response = self.client.get(reverse("login"), follow=True)
self.assertEqual(user_profile, response.wsgi_request.user_profile)

View File

@@ -1,6 +1,5 @@
from django.urls import path, include
from django.urls import re_path
from django.views.generic import RedirectView
from bookmarks import views
from bookmarks.api.routes import router
@@ -14,10 +13,8 @@ from bookmarks.views import partials
app_name = "bookmarks"
urlpatterns = [
# Redirect root to bookmarks index
re_path(
r"^$", RedirectView.as_view(pattern_name="bookmarks:index", permanent=False)
),
# Root view handling redirection based on user authentication
re_path(r"^$", views.root, name="root"),
# Bookmarks
path("bookmarks", views.bookmarks.index, name="index"),
path("bookmarks/action", views.bookmarks.index_action, name="index.action"),

View File

@@ -4,3 +4,4 @@ from .settings import *
from .toasts import *
from .health import health
from .manifest import manifest
from .root import root

View File

@@ -192,6 +192,7 @@ def new(request):
initial_url = request.GET.get("url")
initial_title = request.GET.get("title")
initial_description = request.GET.get("description")
initial_notes = request.GET.get("notes")
initial_auto_close = "auto_close" in request.GET
initial_mark_unread = request.user.profile.default_mark_unread
@@ -214,6 +215,8 @@ def new(request):
form.initial["title"] = initial_title
if initial_description:
form.initial["description"] = initial_description
if initial_notes:
form.initial["notes"] = initial_notes
if initial_auto_close:
form.initial["auto_close"] = "true"
if initial_mark_unread:

18
bookmarks/views/root.py Normal file
View File

@@ -0,0 +1,18 @@
from django.http import HttpResponseRedirect
from django.urls import reverse
from bookmarks.models import GlobalSettings
def root(request):
# Redirect unauthenticated users to the configured landing page
if not request.user.is_authenticated:
settings = GlobalSettings.get()
if settings.landing_page == GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS:
return HttpResponseRedirect(reverse("bookmarks:shared"))
else:
return HttpResponseRedirect(reverse("login"))
# Redirect authenticated users to the bookmarks page
return HttpResponseRedirect(reverse("bookmarks:index"))

View File

@@ -6,13 +6,20 @@ import requests
from django.conf import settings as django_settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.db.models import prefetch_related_objects
from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import render
from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.models import Bookmark, UserProfileForm, FeedToken
from bookmarks.models import (
Bookmark,
UserProfileForm,
FeedToken,
GlobalSettings,
GlobalSettingsForm,
)
from bookmarks.services import exporter, tasks
from bookmarks.services import importer
from bookmarks.utils import app_version
@@ -23,6 +30,7 @@ logger = logging.getLogger(__name__)
@login_required
def general(request):
profile_form = None
global_settings_form = None
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
success_message = _find_message_with_tag(
@@ -37,6 +45,9 @@ def general(request):
if "update_profile" in request.POST:
profile_form = update_profile(request)
success_message = "Profile updated"
if "update_global_settings" in request.POST:
global_settings_form = update_global_settings(request)
success_message = "Global settings updated"
if "refresh_favicons" in request.POST:
tasks.schedule_refresh_favicons(request.user)
success_message = "Scheduled favicon update. This may take a while..."
@@ -52,11 +63,15 @@ def general(request):
if not profile_form:
profile_form = UserProfileForm(instance=request.user_profile)
if request.user.is_superuser and not global_settings_form:
global_settings_form = GlobalSettingsForm(instance=GlobalSettings.get())
return render(
request,
"settings/general.html",
{
"form": profile_form,
"global_settings_form": global_settings_form,
"enable_refresh_favicons": enable_refresh_favicons,
"has_snapshot_support": has_snapshot_support,
"success_message": success_message,
@@ -83,6 +98,17 @@ def update_profile(request):
return form
def update_global_settings(request):
user = request.user
if not user.is_superuser:
raise PermissionDenied()
form = GlobalSettingsForm(request.POST, instance=GlobalSettings.get())
if form.is_valid():
form.save()
return form
# Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1)
def get_version_info(ttl_hash=None):

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.31.1",
"version": "1.32.0",
"description": "",
"main": "index.js",
"scripts": {

View File

@@ -73,7 +73,6 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"bookmarks.context_processors.toasts",
"bookmarks.context_processors.public_shares",
"bookmarks.context_processors.app_version",
],
},

View File

@@ -1 +1 @@
1.31.1
1.32.0