Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to share bookmarks publicly #503

Merged
merged 10 commits into from
Aug 14, 2023
15 changes: 14 additions & 1 deletion bookmarks/api/routes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter

Expand All @@ -18,6 +19,17 @@ class BookmarkViewSet(viewsets.GenericViewSet,
mixins.DestroyModelMixin):
serializer_class = BookmarkSerializer

def get_permissions(self):
# Allow unauthenticated access to shared bookmarks.
# The shared action should still filter bookmarks so that
# unauthenticated users only see bookmarks from users that have public
# sharing explicitly enabled
if self.action == 'shared':
return [AllowAny()]

# Otherwise use default permissions which should require authentication
return super().get_permissions()

def get_queryset(self):
user = self.request.user
# For list action, use query set that applies search and tag projections
Expand Down Expand Up @@ -45,7 +57,8 @@ def archived(self, request):
def shared(self, request):
filters = BookmarkFilters(request)
user = User.objects.filter(username=filters.user).first()
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query)
public_only = not request.user.is_authenticated
query_set = queries.query_shared_bookmarks(user, request.user_profile, filters.query, public_only)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
Expand Down
17 changes: 15 additions & 2 deletions bookmarks/context_processors.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
from bookmarks import queries
from bookmarks.models import Toast


def toasts(request):
user = request.user if hasattr(request, 'user') else None
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user and user.is_authenticated else []
user = request.user
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user.is_authenticated else []
has_toasts = len(toast_messages) > 0

return {
'has_toasts': has_toasts,
'toast_messages': toast_messages,
}


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, '', True)
has_public_shares = query_set.count() > 0
return {
'has_public_shares': has_public_shares,
}

return {}
39 changes: 39 additions & 0 deletions bookmarks/e2e/e2e_test_settings_general.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect

from bookmarks.e2e.helpers import LinkdingE2ETestCase


class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
def test_should_only_enable_public_sharing_if_sharing_is_enabled(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:settings.general'))

enable_sharing = page.get_by_label('Enable bookmark sharing')
enable_sharing_label = page.get_by_text('Enable bookmark sharing')
enable_public_sharing = page.get_by_label('Enable public bookmark sharing')
enable_public_sharing_label = page.get_by_text('Enable public bookmark sharing')

# Public sharing is disabled by default
expect(enable_sharing).not_to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_disabled()

# Enable sharing
enable_sharing_label.click()
expect(enable_sharing).to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_enabled()

# Enable public sharing
enable_public_sharing_label.click()
expect(enable_public_sharing).to_be_checked()
expect(enable_public_sharing).to_be_enabled()

# Disable sharing
enable_sharing_label.click()
expect(enable_sharing).not_to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_disabled()
18 changes: 18 additions & 0 deletions bookmarks/middlewares.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware

from bookmarks.models import UserProfile


class CustomRemoteUserMiddleware(RemoteUserMiddleware):
header = settings.LD_AUTH_PROXY_USERNAME_HEADER


class UserProfileMiddleware:
def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
if request.user.is_authenticated:
request.user_profile = request.user.profile
else:
request.user_profile = UserProfile()
request.user_profile.enable_favicons = True

response = self.get_response(request)

return response
18 changes: 18 additions & 0 deletions bookmarks/migrations/0024_userprofile_enable_public_sharing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.1.9 on 2023-08-14 07:08

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('bookmarks', '0023_userprofile_permanent_notes'),
]

operations = [
migrations.AddField(
model_name='userprofile',
name='enable_public_sharing',
field=models.BooleanField(default=False),
),
]
3 changes: 2 additions & 1 deletion bookmarks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ class UserProfile(models.Model):
tag_search = models.CharField(max_length=10, choices=TAG_SEARCH_CHOICES, blank=False,
default=TAG_SEARCH_STRICT)
enable_sharing = models.BooleanField(default=False, null=False)
enable_public_sharing = models.BooleanField(default=False, null=False)
enable_favicons = models.BooleanField(default=False, null=False)
display_url = models.BooleanField(default=False, null=False)
permanent_notes = models.BooleanField(default=False, null=False)
Expand All @@ -185,7 +186,7 @@ class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search',
'enable_sharing', 'enable_favicons', 'display_url', 'permanent_notes']
'enable_sharing', 'enable_public_sharing', 'enable_favicons', 'display_url', 'permanent_notes']


@receiver(post_save, sender=get_user_model())
Expand Down
20 changes: 12 additions & 8 deletions bookmarks/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ def query_archived_bookmarks(user: User, profile: UserProfile, query_string: str
.filter(is_archived=True)


def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
return _base_bookmarks_query(user, profile, query_string) \
.filter(shared=True) \
.filter(owner__profile__enable_sharing=True)
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str,
public_only: bool) -> QuerySet:
conditions = Q(shared=True) & Q(owner__profile__enable_sharing=True)
if public_only:
conditions = conditions & Q(owner__profile__enable_public_sharing=True)

return _base_bookmarks_query(user, profile, query_string).filter(conditions)


def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
Expand Down Expand Up @@ -85,16 +88,17 @@ def query_archived_bookmark_tags(user: User, profile: UserProfile, query_string:
return query_set.distinct()


def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
bookmarks_query = query_shared_bookmarks(user, profile, query_string)
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str,
public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(user, profile, query_string, public_only)

query_set = Tag.objects.filter(bookmark__in=bookmarks_query)

return query_set.distinct()


def query_shared_bookmark_users(profile: UserProfile, query_string: str) -> QuerySet:
bookmarks_query = query_shared_bookmarks(None, profile, query_string)
def query_shared_bookmark_users(profile: UserProfile, query_string: str, public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(None, profile, query_string, public_only)

query_set = User.objects.filter(bookmark__in=bookmarks_query)

Expand Down
12 changes: 6 additions & 6 deletions bookmarks/templates/bookmarks/bookmark_list.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{% load static %}
{% load shared %}
{% load pagination %}
<ul class="bookmark-list{% if request.user.profile.permanent_notes %} show-notes{% endif %}">
<ul class="bookmark-list{% if request.user_profile.permanent_notes %} show-notes{% endif %}">
{% for bookmark in bookmarks %}
<li data-is-bookmark-item>
<label class="form-checkbox bulk-edit-toggle">
Expand All @@ -11,13 +11,13 @@
<div class="title">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="{% if bookmark.unread %}text-italic{% endif %}">
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
{% if bookmark.favicon_file and request.user_profile.enable_favicons %}
<img src="{% static bookmark.favicon_file %}" alt="">
{% endif %}
{{ bookmark.resolved_title }}
</a>
</div>
{% if request.user.profile.display_url %}
{% if request.user_profile.display_url %}
<div class="url-path truncate">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="url-display text-sm">
Expand Down Expand Up @@ -46,7 +46,7 @@
</div>
{% endif %}
<div class="actions text-gray text-sm">
{% if request.user.profile.bookmark_date_display == 'relative' %}
{% if request.user_profile.bookmark_date_display == 'relative' %}
<span>
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
Expand All @@ -61,7 +61,7 @@
</span>
<span class="separator">|</span>
{% endif %}
{% if request.user.profile.bookmark_date_display == 'absolute' %}
{% if request.user_profile.bookmark_date_display == 'absolute' %}
<span>
{% if bookmark.web_archive_snapshot_url %}
<a href="{{ bookmark.web_archive_snapshot_url }}"
Expand Down Expand Up @@ -103,7 +103,7 @@
<a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
</span>
{% endif %}
{% if bookmark.notes and not request.user.profile.permanent_notes %}
{% if bookmark.notes and not request.user_profile.permanent_notes %}
<span class="separator">|</span>
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16"
Expand Down
8 changes: 6 additions & 2 deletions bookmarks/templates/bookmarks/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,19 @@
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div>
</div>
{% if request.user.profile.enable_sharing %}
{% if request.user_profile.enable_sharing %}
<div class="form-group">
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
{{ form.shared }}
<i class="form-icon"></i>
<span>Share</span>
</label>
<div class="form-input-hint">
Share this bookmark with other users.
{% if request.user_profile.enable_public_sharing %}
Share this bookmark with other registered users and anonymous users.
{% else %}
Share this bookmark with other registered users.
{% endif %}
</div>
</div>
{% endif %}
Expand Down
11 changes: 8 additions & 3 deletions bookmarks/templates/bookmarks/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
<title>linkding</title>
{# Include SASS styles, files are resolved from bookmarks/styles #}
{# Include specific theme variant based on user profile setting #}
{% if request.user.profile.theme == 'light' %}
{% if request.user_profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/>
{% elif request.user.profile.theme == 'dark' %}
{% elif request.user_profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/>
{% else %}
{# Use auto theme as fallback #}
Expand Down Expand Up @@ -51,11 +51,16 @@
<h1>linkding</h1>
</a>
</section>
{# Only show nav items menu when logged in #}
{% if request.user.is_authenticated %}
{# Only show nav items menu when logged in #}
<section class="navbar-section">
{% include 'bookmarks/nav_menu.html' %}
</section>
{% elif has_public_shares %}
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
<section class="navbar-section">
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
</section>
{% endif %}
</div>
</header>
Expand Down
4 changes: 2 additions & 2 deletions bookmarks/templates/bookmarks/nav_menu.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
{% if request.user.profile.enable_sharing %}
{% if request.user_profile.enable_sharing %}
<li>
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li>
Expand Down Expand Up @@ -59,7 +59,7 @@
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li>
{% if request.user.profile.enable_sharing %}
{% if request.user_profile.enable_sharing %}
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li>
Expand Down
34 changes: 32 additions & 2 deletions bookmarks/templates/settings/general.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ <h2>Profile</h2>
<div class="form-input-hint">
In strict mode, tags must be prefixed with a hash character (#).
In lax mode, tags can also be searched without the hash character.
Note that tags without the hash character are indistinguishable from search terms, which means the search result will also include bookmarks where a search term matches otherwise.
Note that tags without the hash character are indistinguishable from search terms, which means the search
result will also include bookmarks where a search term matches otherwise.
</div>
</div>
<div class="form-group">
Expand All @@ -77,7 +78,7 @@ <h2>Profile</h2>
documentation</a> on how to configure a custom favicon provider.
Icons are downloaded in the background, and it may take a while for them to show up.
</div>
{% if request.user.profile.enable_favicons and enable_refresh_favicons %}
{% if request.user_profile.enable_favicons and enable_refresh_favicons %}
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
{% endif %}
{% if refresh_favicons_success_message %}
Expand Down Expand Up @@ -112,6 +113,17 @@ <h2>Profile</h2>
Disabling this feature will hide all previously shared bookmarks from other users.
</div>
</div>
<div class="form-group">
<label for="{{ form.enable_public_sharing.id_for_label }}" class="form-checkbox">
{{ form.enable_public_sharing }}
<i class="form-icon"></i> Enable public bookmark sharing
</label>
<div class="form-input-hint">
Makes shared bookmarks publicly accessible, without requiring a login.
That means that anyone with a link to this instance can view shared bookmarks via the <a
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
</div>
</div>
<div class="form-group">
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
{% if update_profile_success_message %}
Expand Down Expand Up @@ -196,4 +208,22 @@ <h2>About</h2>
</section>
</div>

<script>
// Automatically disable public bookmark sharing if bookmark sharing is disabled
const enableSharing = document.getElementById("{{ form.enable_sharing.id_for_label }}");
const enablePublicSharing = document.getElementById("{{ form.enable_public_sharing.id_for_label }}");

function updatePublicSharing() {
if (enableSharing.checked) {
enablePublicSharing.disabled = false;
} else {
enablePublicSharing.disabled = true;
enablePublicSharing.checked = false;
}
}

updatePublicSharing();
enableSharing.addEventListener("change", updatePublicSharing);
</script>

{% endblock %}
2 changes: 1 addition & 1 deletion bookmarks/templatetags/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def remove_tag_from_query(context, tag_name: str):
tag_name_with_hash = '#' + tag_name
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name_with_hash)]
# When using lax tag search, also remove tag without hash
profile = context.request.user.profile
profile = context.request.user_profile
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name)]
# Rebuild query string
Expand Down
Loading