From 814401be2eb221982bfcd21a7be253d5f17a1b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Sat, 21 Jan 2023 16:36:10 +0100 Subject: [PATCH] Add option for showing bookmark favicons (#390) * Implement favicon loader * Implement load favicon task * Show favicons in bookmark list * Add missing migration * Load missing favicons on import * Automatically refresh favicons * Add enable favicon setting * Update uwsgi config to host favicons * Improve settings wording * Fix favicon loader test setup * Document LD_FAVICON_PROVIDER setting * Add refresh favicons button --- .../migrations/0018_bookmark_favicon_file.py | 18 ++ .../0019_userprofile_enable_favicons.py | 18 ++ bookmarks/models.py | 4 +- bookmarks/services/bookmarks.py | 5 + bookmarks/services/favicon_loader.py | 57 +++++ bookmarks/services/importer.py | 2 + bookmarks/services/tasks.py | 69 +++++- bookmarks/styles/bookmarks.scss | 6 + .../templates/bookmarks/bookmark_list.html | 4 + bookmarks/templates/settings/general.html | 33 ++- bookmarks/tests/helpers.py | 2 + bookmarks/tests/test_bookmarks_list_tag.py | 41 ++++ bookmarks/tests/test_bookmarks_service.py | 15 ++ bookmarks/tests/test_bookmarks_tasks.py | 218 +++++++++++++++--- bookmarks/tests/test_favicon_loader.py | 127 ++++++++++ bookmarks/tests/test_importer.py | 9 + bookmarks/tests/test_settings_general_view.py | 138 ++++++++++- bookmarks/views/settings.py | 43 +++- bootstrap.sh | 2 + docs/Options.md | 12 + siteroot/settings/base.py | 7 + uwsgi.ini | 3 +- 22 files changed, 786 insertions(+), 47 deletions(-) create mode 100644 bookmarks/migrations/0018_bookmark_favicon_file.py create mode 100644 bookmarks/migrations/0019_userprofile_enable_favicons.py create mode 100644 bookmarks/services/favicon_loader.py create mode 100644 bookmarks/tests/test_favicon_loader.py diff --git a/bookmarks/migrations/0018_bookmark_favicon_file.py b/bookmarks/migrations/0018_bookmark_favicon_file.py new file mode 100644 index 0000000..a61eb22 --- /dev/null +++ b/bookmarks/migrations/0018_bookmark_favicon_file.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1 on 2023-01-07 23:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookmarks', '0017_userprofile_enable_sharing'), + ] + + operations = [ + migrations.AddField( + model_name='bookmark', + name='favicon_file', + field=models.CharField(blank=True, max_length=512), + ), + ] diff --git a/bookmarks/migrations/0019_userprofile_enable_favicons.py b/bookmarks/migrations/0019_userprofile_enable_favicons.py new file mode 100644 index 0000000..c64ff87 --- /dev/null +++ b/bookmarks/migrations/0019_userprofile_enable_favicons.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1 on 2023-01-09 21:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookmarks', '0018_bookmark_favicon_file'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='enable_favicons', + field=models.BooleanField(default=False), + ), + ] diff --git a/bookmarks/models.py b/bookmarks/models.py index bec325e..c652474 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -53,6 +53,7 @@ class Bookmark(models.Model): website_title = models.CharField(max_length=512, blank=True, null=True) website_description = models.TextField(blank=True, null=True) web_archive_snapshot_url = models.CharField(max_length=2048, blank=True) + favicon_file = models.CharField(max_length=512, blank=True) unread = models.BooleanField(default=False) is_archived = models.BooleanField(default=False) shared = models.BooleanField(default=False) @@ -161,12 +162,13 @@ class UserProfile(models.Model): web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False, default=WEB_ARCHIVE_INTEGRATION_DISABLED) enable_sharing = models.BooleanField(default=False, null=False) + enable_favicons = models.BooleanField(default=False, null=False) class UserProfileForm(forms.ModelForm): class Meta: model = UserProfile - fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing'] + fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing', 'enable_favicons'] @receiver(post_save, sender=get_user_model()) diff --git a/bookmarks/services/bookmarks.py b/bookmarks/services/bookmarks.py index c499e64..2c6679e 100644 --- a/bookmarks/services/bookmarks.py +++ b/bookmarks/services/bookmarks.py @@ -30,6 +30,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User): bookmark.save() # Create snapshot on web archive tasks.create_web_archive_snapshot(current_user, bookmark, False) + # Load favicon + tasks.load_favicon(current_user, bookmark) return bookmark @@ -43,6 +45,9 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User): # Update dates bookmark.date_modified = timezone.now() bookmark.save() + # Update favicon + tasks.load_favicon(current_user, bookmark) + if has_url_changed: # Update web archive snapshot, if URL changed tasks.create_web_archive_snapshot(current_user, bookmark, True) diff --git a/bookmarks/services/favicon_loader.py b/bookmarks/services/favicon_loader.py new file mode 100644 index 0000000..ac77b1c --- /dev/null +++ b/bookmarks/services/favicon_loader.py @@ -0,0 +1,57 @@ +import os.path +import re +import shutil +import time +from pathlib import Path +from urllib.parse import urlparse + +import requests +from django.conf import settings + +max_file_age = 60 * 60 * 24 # 1 day + + +def _ensure_favicon_folder(): + Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True) + + +def _url_to_filename(url: str) -> str: + name = re.sub(r'\W+', '_', url) + return f'{name}.png' + + +def _get_base_url(url: str) -> str: + parsed_uri = urlparse(url) + return f'{parsed_uri.scheme}://{parsed_uri.hostname}' + + +def _get_favicon_path(favicon_file: str) -> Path: + return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file)) + + +def _is_stale(path: Path) -> bool: + stat = path.stat() + file_age = time.time() - stat.st_mtime + return file_age >= max_file_age + + +def load_favicon(url: str) -> str: + # Get base URL so that we can reuse favicons for multiple bookmarks with the same host + base_url = _get_base_url(url) + favicon_name = _url_to_filename(base_url) + favicon_path = _get_favicon_path(favicon_name) + + # Load icon if it doesn't exist yet or has become stale + if not favicon_path.exists() or _is_stale(favicon_path): + # Create favicon folder if not exists + _ensure_favicon_folder() + # Load favicon from provider, save to file + favicon_url = settings.LD_FAVICON_PROVIDER.format(url=base_url) + response = requests.get(favicon_url, stream=True) + + with open(favicon_path, 'wb') as file: + shutil.copyfileobj(response.raw, file) + + del response + + return favicon_name diff --git a/bookmarks/services/importer.py b/bookmarks/services/importer.py index fd9c315..588c86c 100644 --- a/bookmarks/services/importer.py +++ b/bookmarks/services/importer.py @@ -74,6 +74,8 @@ def import_netscape_html(html: str, user: User): # Create snapshots for newly imported bookmarks tasks.schedule_bookmarks_without_snapshots(user) + # Load favicons for newly imported bookmarks + tasks.schedule_bookmarks_without_favicons(user) end = timezone.now() logger.debug(f'Import duration: {end - import_start}') diff --git a/bookmarks/services/tasks.py b/bookmarks/services/tasks.py index 16e4c1c..13ea32f 100644 --- a/bookmarks/services/tasks.py +++ b/bookmarks/services/tasks.py @@ -2,6 +2,7 @@ import logging import waybackpy from background_task import background +from background_task.models import Task from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import User @@ -10,6 +11,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord import bookmarks.services.wayback from bookmarks.models import Bookmark, UserProfile from bookmarks.services.website_loader import DEFAULT_USER_AGENT +from bookmarks.services import favicon_loader logger = logging.getLogger(__name__) @@ -72,7 +74,8 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool): logger.error( f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}') except WaybackError as error: - logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}', exc_info=error) + logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}', + exc_info=error) # Load the newest snapshot as fallback _load_newest_snapshot(bookmark) @@ -105,3 +108,67 @@ def _schedule_bookmarks_without_snapshots_task(user_id: int): # To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating # new ones when processing bookmarks in bulk _load_web_archive_snapshot_task(bookmark.id) + + +def is_favicon_feature_active(user: User) -> bool: + background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS + + return background_tasks_enabled and user.profile.enable_favicons + + +def load_favicon(user: User, bookmark: Bookmark): + if is_favicon_feature_active(user): + _load_favicon_task(bookmark.id) + + +@background() +def _load_favicon_task(bookmark_id: int): + try: + bookmark = Bookmark.objects.get(id=bookmark_id) + except Bookmark.DoesNotExist: + return + + logger.info(f'Load favicon for bookmark. url={bookmark.url}') + + new_favicon = favicon_loader.load_favicon(bookmark.url) + + if new_favicon != bookmark.favicon_file: + bookmark.favicon_file = new_favicon + bookmark.save() + logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon}') + + +def schedule_bookmarks_without_favicons(user: User): + if is_favicon_feature_active(user): + _schedule_bookmarks_without_favicons_task(user.id) + + +@background() +def _schedule_bookmarks_without_favicons_task(user_id: int): + user = get_user_model().objects.get(id=user_id) + bookmarks = Bookmark.objects.filter(favicon_file__exact='', owner=user) + tasks = [] + + for bookmark in bookmarks: + task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,)) + tasks.append(task) + + Task.objects.bulk_create(tasks) + + +def schedule_refresh_favicons(user: User): + if is_favicon_feature_active(user) and settings.LD_ENABLE_REFRESH_FAVICONS: + _schedule_refresh_favicons_task(user.id) + + +@background() +def _schedule_refresh_favicons_task(user_id: int): + user = get_user_model().objects.get(id=user_id) + bookmarks = Bookmark.objects.filter(owner=user) + tasks = [] + + for bookmark in bookmarks: + task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,)) + tasks.append(task) + + Task.objects.bulk_create(tasks) diff --git a/bookmarks/styles/bookmarks.scss b/bookmarks/styles/bookmarks.scss index 8ed4bc0..b523378 100644 --- a/bookmarks/styles/bookmarks.scss +++ b/bookmarks/styles/bookmarks.scss @@ -58,6 +58,12 @@ ul.bookmark-list { text-overflow: ellipsis; } + .title img { + width: 16px; + height: 16px; + vertical-align: text-top; + } + .description { color: $gray-color-dark; diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html index 5cbae67..1efdf3b 100644 --- a/bookmarks/templates/bookmarks/bookmark_list.html +++ b/bookmarks/templates/bookmarks/bookmark_list.html @@ -1,3 +1,4 @@ +{% load static %} {% load shared %} {% load pagination %} {% htmlmin %} @@ -11,6 +12,9 @@
+ {% if bookmark.favicon_file and request.user.profile.enable_favicons %} + + {% endif %} {{ bookmark.resolved_title }}
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html index 6c4fbcc..94c58a5 100644 --- a/bookmarks/templates/settings/general.html +++ b/bookmarks/templates/settings/general.html @@ -36,6 +36,30 @@ Whether to open bookmarks a new page or in the same page. +
+ +
+ Automatically loads favicons for bookmarked websites and displays them next to each bookmark. + By default, this feature uses a Google service to download favicons. + If you don't want to use this service, check the options + documentation 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. +
+ {% if request.user.profile.enable_favicons and enable_refresh_favicons %} + + {% endif %} + {% if refresh_favicons_success_message %} +
+

+ {{ refresh_favicons_success_message }} +

+
+ {% endif %} +
@@ -61,7 +85,14 @@
- + + {% if update_profile_success_message %} +
+

+ {{ update_profile_success_message }} +

+
+ {% endif %}
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py index ef57ed1..e0e87a5 100644 --- a/bookmarks/tests/helpers.py +++ b/bookmarks/tests/helpers.py @@ -33,6 +33,7 @@ class BookmarkFactoryMixin: website_title: str = '', website_description: str = '', web_archive_snapshot_url: str = '', + favicon_file: str = '', ): if not title: title = get_random_string(length=32) @@ -56,6 +57,7 @@ class BookmarkFactoryMixin: unread=unread, shared=shared, web_archive_snapshot_url=web_archive_snapshot_url, + favicon_file=favicon_file, ) bookmark.save() for tag in tags: diff --git a/bookmarks/tests/test_bookmarks_list_tag.py b/bookmarks/tests/test_bookmarks_list_tag.py index 725946c..0178852 100644 --- a/bookmarks/tests/test_bookmarks_list_tag.py +++ b/bookmarks/tests/test_bookmarks_list_tag.py @@ -79,6 +79,17 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): ''', html, count=count) + def assertFaviconVisible(self, html: str, bookmark: Bookmark): + self.assertFaviconCount(html, bookmark, 1) + + def assertFaviconHidden(self, html: str, bookmark: Bookmark): + self.assertFaviconCount(html, bookmark, 0) + + def assertFaviconCount(self, html: str, bookmark: Bookmark, count=1): + self.assertInHTML(f''' + + ''', html, count=count) + def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str: rf = RequestFactory() request = rf.get(url) @@ -211,3 +222,33 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin): {bookmark.owner.username} ''', html) + + def test_favicon_should_be_visible_when_favicons_enabled(self): + profile = self.get_or_create_test_user().profile + profile.enable_favicons = True + profile.save() + + bookmark = self.setup_bookmark(favicon_file='https_example_com.png') + html = self.render_default_template([bookmark]) + + self.assertFaviconVisible(html, bookmark) + + def test_favicon_should_be_hidden_when_there_is_no_icon(self): + profile = self.get_or_create_test_user().profile + profile.enable_favicons = True + profile.save() + + bookmark = self.setup_bookmark(favicon_file='') + html = self.render_default_template([bookmark]) + + self.assertFaviconHidden(html, bookmark) + + def test_favicon_should_be_hidden_when_favicons_disabled(self): + profile = self.get_or_create_test_user().profile + profile.enable_favicons = False + profile.save() + + bookmark = self.setup_bookmark(favicon_file='https_example_com.png') + html = self.render_default_template([bookmark]) + + self.assertFaviconHidden(html, bookmark) diff --git a/bookmarks/tests/test_bookmarks_service.py b/bookmarks/tests/test_bookmarks_service.py index 98dfcb0..df10c84 100644 --- a/bookmarks/tests/test_bookmarks_service.py +++ b/bookmarks/tests/test_bookmarks_service.py @@ -67,6 +67,13 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin): mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False) + def test_create_should_load_favicon(self): + with patch.object(tasks, 'load_favicon') as mock_load_favicon: + bookmark_data = Bookmark(url='https://example.com') + bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user) + + mock_load_favicon.assert_called_once_with(self.user, bookmark) + def test_update_should_create_web_archive_snapshot_if_url_did_change(self): with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot: bookmark = self.setup_bookmark() @@ -109,6 +116,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin): mock_load_website_metadata.assert_not_called() + def test_update_should_update_favicon(self): + with patch.object(tasks, 'load_favicon') as mock_load_favicon: + bookmark = self.setup_bookmark() + bookmark.title = 'updated title' + update_bookmark(bookmark, 'tag1,tag2', self.user) + + mock_load_favicon.assert_called_once_with(self.user, bookmark) + def test_archive_bookmark(self): bookmark = Bookmark( url='https://example.com', diff --git a/bookmarks/tests/test_bookmarks_tasks.py b/bookmarks/tests/test_bookmarks_tasks.py index e0a3c73..74fc443 100644 --- a/bookmarks/tests/test_bookmarks_tasks.py +++ b/bookmarks/tests/test_bookmarks_tasks.py @@ -1,6 +1,6 @@ import datetime from dataclasses import dataclass -from unittest.mock import patch +from unittest import mock import waybackpy from background_task.models import Task @@ -8,6 +8,7 @@ from django.contrib.auth.models import User from django.test import TestCase, override_settings from waybackpy.exceptions import WaybackError +import bookmarks.services.favicon_loader import bookmarks.services.wayback from bookmarks.models import UserProfile from bookmarks.services import tasks @@ -53,12 +54,14 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def setUp(self): user = self.get_or_create_test_user() user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED + user.profile.enable_favicons = True user.profile.save() @disable_logging def run_pending_task(self, task_function): func = getattr(task_function, 'task_function', None) task = Task.objects.all()[0] + self.assertEqual(task_function.name, task.task_name) args, kwargs = task.params() func(*args, **kwargs) task.delete() @@ -69,6 +72,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): tasks = Task.objects.all() for task in tasks: + self.assertEqual(task_function.name, task.task_name) args, kwargs = task.params() func(*args, **kwargs) task.delete() @@ -76,7 +80,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def test_create_web_archive_snapshot_should_update_snapshot_url(self): bookmark = self.setup_bookmark() - with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()): + with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()): tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) self.run_pending_task(tasks._create_web_archive_snapshot_task) bookmark.refresh_from_db() @@ -84,8 +88,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot') def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self): - with patch.object(waybackpy, 'WaybackMachineSaveAPI', - return_value=MockWaybackMachineSaveAPI()) as mock_save_api: + with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', + return_value=MockWaybackMachineSaveAPI()) as mock_save_api: tasks._create_web_archive_snapshot_task(123, False) self.run_pending_task(tasks._create_web_archive_snapshot_task) @@ -94,8 +98,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self): bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com') - with patch.object(waybackpy, 'WaybackMachineSaveAPI', - return_value=MockWaybackMachineSaveAPI()) as mock_save_api: + with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', + return_value=MockWaybackMachineSaveAPI()) as mock_save_api: tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) self.run_pending_task(tasks._create_web_archive_snapshot_task) @@ -104,8 +108,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def test_create_web_archive_snapshot_should_force_update_snapshot(self): bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com') - with patch.object(waybackpy, 'WaybackMachineSaveAPI', - return_value=MockWaybackMachineSaveAPI('https://other.com')): + with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', + return_value=MockWaybackMachineSaveAPI('https://other.com')): tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True) self.run_pending_task(tasks._create_web_archive_snapshot_task) bookmark.refresh_from_db() @@ -115,10 +119,10 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self): bookmark = self.setup_bookmark() - with patch.object(waybackpy, 'WaybackMachineSaveAPI', - return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): - with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', - return_value=MockWaybackMachineCDXServerAPI()): + with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', + return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): + with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', + return_value=MockWaybackMachineCDXServerAPI()): tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) self.run_pending_task(tasks._create_web_archive_snapshot_task) @@ -128,10 +132,10 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self): bookmark = self.setup_bookmark() - with patch.object(waybackpy, 'WaybackMachineSaveAPI', - return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): - with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', - return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)): + with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', + return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): + with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', + return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)): tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) self.run_pending_task(tasks._create_web_archive_snapshot_task) @@ -141,10 +145,10 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self): bookmark = self.setup_bookmark() - with patch.object(waybackpy, 'WaybackMachineSaveAPI', - return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): - with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', - return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)): + with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', + return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): + with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', + return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)): tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) self.run_pending_task(tasks._create_web_archive_snapshot_task) @@ -154,8 +158,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def test_load_web_archive_snapshot_should_update_snapshot_url(self): bookmark = self.setup_bookmark() - with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', - return_value=MockWaybackMachineCDXServerAPI()): + with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', + return_value=MockWaybackMachineCDXServerAPI()): tasks._load_web_archive_snapshot_task(bookmark.id) self.run_pending_task(tasks._load_web_archive_snapshot_task) @@ -163,8 +167,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url) def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self): - with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', - return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api: + with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', + return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api: tasks._load_web_archive_snapshot_task(123) self.run_pending_task(tasks._load_web_archive_snapshot_task) @@ -173,8 +177,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self): bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com') - with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', - return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api: + with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', + return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api: tasks._load_web_archive_snapshot_task(bookmark.id) self.run_pending_task(tasks._load_web_archive_snapshot_task) @@ -183,8 +187,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def test_load_web_archive_snapshot_should_handle_missing_snapshot(self): bookmark = self.setup_bookmark() - with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', - return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)): + with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', + return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)): tasks._load_web_archive_snapshot_task(bookmark.id) self.run_pending_task(tasks._load_web_archive_snapshot_task) @@ -193,8 +197,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): def test_load_web_archive_snapshot_should_handle_wayback_errors(self): bookmark = self.setup_bookmark() - with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', - return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)): + with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', + return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)): tasks._load_web_archive_snapshot_task(bookmark.id) self.run_pending_task(tasks._load_web_archive_snapshot_task) @@ -262,3 +266,157 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): tasks.schedule_bookmarks_without_snapshots(self.user) self.assertEqual(Task.objects.count(), 0) + + def test_load_favicon_should_create_favicon_file(self): + bookmark = self.setup_bookmark() + + with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon: + mock_load_favicon.return_value = 'https_example_com.png' + + tasks.load_favicon(self.get_or_create_test_user(), bookmark) + self.run_pending_task(tasks._load_favicon_task) + bookmark.refresh_from_db() + + self.assertEqual(bookmark.favicon_file, 'https_example_com.png') + + def test_load_favicon_should_update_favicon_file(self): + bookmark = self.setup_bookmark(favicon_file='https_example_com.png') + + with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon: + mock_load_favicon.return_value = 'https_example_updated_com.png' + tasks.load_favicon(self.get_or_create_test_user(), bookmark) + self.run_pending_task(tasks._load_favicon_task) + + mock_load_favicon.assert_called() + bookmark.refresh_from_db() + self.assertEqual(bookmark.favicon_file, 'https_example_updated_com.png') + + def test_load_favicon_should_handle_missing_bookmark(self): + with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon: + tasks._load_favicon_task(123) + self.run_pending_task(tasks._load_favicon_task) + + mock_load_favicon.assert_not_called() + + @override_settings(LD_DISABLE_BACKGROUND_TASKS=True) + def test_load_favicon_should_not_run_when_background_tasks_are_disabled(self): + bookmark = self.setup_bookmark() + tasks.load_favicon(self.get_or_create_test_user(), bookmark) + + self.assertEqual(Task.objects.count(), 0) + + def test_load_favicon_should_not_run_when_favicon_feature_is_disabled(self): + self.user.profile.enable_favicons = False + self.user.profile.save() + + bookmark = self.setup_bookmark() + tasks.load_favicon(self.get_or_create_test_user(), bookmark) + + self.assertEqual(Task.objects.count(), 0) + + def test_schedule_bookmarks_without_favicons_should_load_favicon_for_all_bookmarks_without_favicon(self): + user = self.get_or_create_test_user() + self.setup_bookmark() + self.setup_bookmark() + self.setup_bookmark() + self.setup_bookmark(favicon_file='https_example_com.png') + self.setup_bookmark(favicon_file='https_example_com.png') + self.setup_bookmark(favicon_file='https_example_com.png') + + tasks.schedule_bookmarks_without_favicons(user) + self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task) + + task_list = Task.objects.all() + self.assertEqual(task_list.count(), 3) + + for task in task_list: + self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_favicon_task') + + def test_schedule_bookmarks_without_favicons_should_only_update_user_owned_bookmarks(self): + user = self.get_or_create_test_user() + other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') + self.setup_bookmark() + self.setup_bookmark() + self.setup_bookmark() + self.setup_bookmark(user=other_user) + self.setup_bookmark(user=other_user) + self.setup_bookmark(user=other_user) + + tasks.schedule_bookmarks_without_favicons(user) + self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task) + + task_list = Task.objects.all() + self.assertEqual(task_list.count(), 3) + + @override_settings(LD_DISABLE_BACKGROUND_TASKS=True) + def test_schedule_bookmarks_without_favicons_should_not_run_when_background_tasks_are_disabled(self): + bookmark = self.setup_bookmark() + tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user()) + + self.assertEqual(Task.objects.count(), 0) + + def test_schedule_bookmarks_without_favicons_should_not_run_when_favicon_feature_is_disabled(self): + self.user.profile.enable_favicons = False + self.user.profile.save() + + bookmark = self.setup_bookmark() + tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user()) + + self.assertEqual(Task.objects.count(), 0) + + def test_schedule_refresh_favicons_should_update_favicon_for_all_bookmarks(self): + user = self.get_or_create_test_user() + self.setup_bookmark() + self.setup_bookmark() + self.setup_bookmark() + self.setup_bookmark(favicon_file='https_example_com.png') + self.setup_bookmark(favicon_file='https_example_com.png') + self.setup_bookmark(favicon_file='https_example_com.png') + + tasks.schedule_refresh_favicons(user) + self.run_pending_task(tasks._schedule_refresh_favicons_task) + + task_list = Task.objects.all() + self.assertEqual(task_list.count(), 6) + + for task in task_list: + self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_favicon_task') + + def test_schedule_refresh_favicons_should_only_update_user_owned_bookmarks(self): + user = self.get_or_create_test_user() + other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') + self.setup_bookmark() + self.setup_bookmark() + self.setup_bookmark() + self.setup_bookmark(user=other_user) + self.setup_bookmark(user=other_user) + self.setup_bookmark(user=other_user) + + tasks.schedule_refresh_favicons(user) + self.run_pending_task(tasks._schedule_refresh_favicons_task) + + task_list = Task.objects.all() + self.assertEqual(task_list.count(), 3) + + @override_settings(LD_DISABLE_BACKGROUND_TASKS=True) + def test_schedule_refresh_favicons_should_not_run_when_background_tasks_are_disabled(self): + self.setup_bookmark() + tasks.schedule_refresh_favicons(self.get_or_create_test_user()) + + self.assertEqual(Task.objects.count(), 0) + + @override_settings(LD_ENABLE_REFRESH_FAVICONS=False) + def test_schedule_refresh_favicons_should_not_run_when_refresh_is_disabled(self): + self.setup_bookmark() + tasks.schedule_refresh_favicons(self.get_or_create_test_user()) + + self.assertEqual(Task.objects.count(), 0) + + def test_schedule_refresh_favicons_should_not_run_when_favicon_feature_is_disabled(self): + self.user.profile.enable_favicons = False + self.user.profile.save() + + self.setup_bookmark() + tasks.schedule_refresh_favicons(self.get_or_create_test_user()) + + self.assertEqual(Task.objects.count(), 0) diff --git a/bookmarks/tests/test_favicon_loader.py b/bookmarks/tests/test_favicon_loader.py new file mode 100644 index 0000000..cdffff9 --- /dev/null +++ b/bookmarks/tests/test_favicon_loader.py @@ -0,0 +1,127 @@ +import io +import os.path +import time +from pathlib import Path +from unittest import mock + +from django.conf import settings +from django.test import TestCase + +from bookmarks.services import favicon_loader + +mock_icon_data = b'mock_icon' + + +class FaviconLoaderTestCase(TestCase): + def setUp(self) -> None: + self.ensure_favicon_folder() + self.clear_favicon_folder() + + def create_mock_response(self, icon_data=mock_icon_data): + mock_response = mock.Mock() + mock_response.raw = io.BytesIO(icon_data) + return mock_response + + def ensure_favicon_folder(self): + Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True) + + def clear_favicon_folder(self): + folder = Path(settings.LD_FAVICON_FOLDER) + for file in folder.iterdir(): + file.unlink() + + def get_icon_path(self, filename): + return Path(os.path.join(settings.LD_FAVICON_FOLDER, filename)) + + def icon_exists(self, filename): + return self.get_icon_path(filename).exists() + + def get_icon_data(self, filename): + return self.get_icon_path(filename).read_bytes() + + def count_icons(self): + files = os.listdir(settings.LD_FAVICON_FOLDER) + return len(files) + + def test_load_favicon(self): + with mock.patch('requests.get') as mock_get: + mock_get.return_value = self.create_mock_response() + favicon_loader.load_favicon('https://example.com') + + # should create icon file + self.assertTrue(self.icon_exists('https_example_com.png')) + + # should store image data + self.assertEqual(mock_icon_data, self.get_icon_data('https_example_com.png')) + + def test_load_favicon_creates_folder_if_not_exists(self): + with mock.patch('requests.get') as mock_get: + mock_get.return_value = self.create_mock_response() + + folder = Path(settings.LD_FAVICON_FOLDER) + folder.rmdir() + + self.assertFalse(folder.exists()) + + favicon_loader.load_favicon('https://example.com') + + self.assertTrue(folder.exists()) + + def test_load_favicon_creates_single_icon_for_same_base_url(self): + with mock.patch('requests.get') as mock_get: + mock_get.return_value = self.create_mock_response() + favicon_loader.load_favicon('https://example.com') + favicon_loader.load_favicon('https://example.com?foo=bar') + favicon_loader.load_favicon('https://example.com/foo') + + self.assertEqual(1, self.count_icons()) + self.assertTrue(self.icon_exists('https_example_com.png')) + + def test_load_favicon_creates_multiple_icons_for_different_base_url(self): + with mock.patch('requests.get') as mock_get: + mock_get.return_value = self.create_mock_response() + favicon_loader.load_favicon('https://example.com') + favicon_loader.load_favicon('https://sub.example.com') + favicon_loader.load_favicon('https://other-domain.com') + + self.assertEqual(3, self.count_icons()) + self.assertTrue(self.icon_exists('https_example_com.png')) + self.assertTrue(self.icon_exists('https_sub_example_com.png')) + self.assertTrue(self.icon_exists('https_other_domain_com.png')) + + def test_load_favicon_caches_icons(self): + with mock.patch('requests.get') as mock_get: + mock_get.return_value = self.create_mock_response() + + favicon_loader.load_favicon('https://example.com') + mock_get.assert_called() + + mock_get.reset_mock() + favicon_loader.load_favicon('https://example.com') + mock_get.assert_not_called() + + def test_load_favicon_updates_stale_icon(self): + with mock.patch('requests.get') as mock_get: + mock_get.return_value = self.create_mock_response() + favicon_loader.load_favicon('https://example.com') + + icon_path = self.get_icon_path('https_example_com.png') + + updated_mock_icon_data = b'updated_mock_icon' + mock_get.return_value = self.create_mock_response(icon_data=updated_mock_icon_data) + mock_get.reset_mock() + + # change icon modification date so it is not stale yet + nearly_one_day_ago = time.time() - 60 * 60 * 23 + os.utime(icon_path.absolute(), (nearly_one_day_ago, nearly_one_day_ago)) + + favicon_loader.load_favicon('https://example.com') + mock_get.assert_not_called() + + # change icon modification date so it is considered stale + one_day_ago = time.time() - 60 * 60 * 24 + os.utime(icon_path.absolute(), (one_day_ago, one_day_ago)) + + favicon_loader.load_favicon('https://example.com') + mock_get.assert_called() + self.assertEqual(updated_mock_icon_data, self.get_icon_data('https_example_com.png')) diff --git a/bookmarks/tests/test_importer.py b/bookmarks/tests/test_importer.py index b94bd41..24aef88 100644 --- a/bookmarks/tests/test_importer.py +++ b/bookmarks/tests/test_importer.py @@ -262,3 +262,12 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin): import_netscape_html(test_html, user) mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user) + + def test_schedule_favicon_loading(self): + user = self.get_or_create_test_user() + test_html = self.render_html(tags_html='') + + with patch.object(tasks, 'schedule_bookmarks_without_favicons') as mock_schedule_bookmarks_without_favicons: + import_netscape_html(test_html, user) + + mock_schedule_bookmarks_without_favicons.assert_called_once_with(user) diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py index ea515bb..042c9d7 100644 --- a/bookmarks/tests/test_settings_general_view.py +++ b/bookmarks/tests/test_settings_general_view.py @@ -1,12 +1,13 @@ import random - -from django.test import TestCase -from django.urls import reverse from unittest.mock import patch, Mock + import requests +from django.test import TestCase, override_settings +from django.urls import reverse from requests import RequestException from bookmarks.models import UserProfile +from bookmarks.services import tasks from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.views.settings import app_version, get_version_info @@ -17,6 +18,20 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin): user = self.get_or_create_test_user() self.client.force_login(user) + def create_profile_form_data(self, overrides=None): + if not overrides: + overrides = {} + form_data = { + 'theme': UserProfile.THEME_AUTO, + 'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE, + 'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_BLANK, + 'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED, + 'enable_sharing': False, + 'enable_favicons': False, + } + + return {**form_data, **overrides} + def test_should_render_successfully(self): response = self.client.get(reverse('bookmarks:settings.general')) @@ -28,15 +43,18 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin): self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general')) - def test_should_save_profile(self): + def test_update_profile(self): form_data = { + 'update_profile': '', 'theme': UserProfile.THEME_DARK, 'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN, 'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF, 'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED, 'enable_sharing': True, + 'enable_favicons': True, } response = self.client.post(reverse('bookmarks:settings.general'), form_data) + html = response.content.decode() self.user.profile.refresh_from_db() @@ -46,6 +64,118 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin): self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target']) self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration']) self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing']) + self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons']) + self.assertInHTML(''' +

Profile updated

+ ''', html) + + def test_update_profile_should_not_be_called_without_respective_form_action(self): + form_data = { + 'theme': UserProfile.THEME_DARK, + } + response = self.client.post(reverse('bookmarks:settings.general'), form_data) + html = response.content.decode() + + self.user.profile.refresh_from_db() + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO) + self.assertInHTML(''' +

Profile updated

+ ''', html, count=0) + + def test_enable_favicons_should_schedule_icon_update(self): + with patch.object(tasks, 'schedule_bookmarks_without_favicons') as mock_schedule_bookmarks_without_favicons: + # Enabling favicons schedules update + form_data = self.create_profile_form_data({ + 'update_profile': '', + 'enable_favicons': True, + }) + self.client.post(reverse('bookmarks:settings.general'), form_data) + + mock_schedule_bookmarks_without_favicons.assert_called_once_with(self.user) + + # No update scheduled if favicons are already enabled + mock_schedule_bookmarks_without_favicons.reset_mock() + + self.client.post(reverse('bookmarks:settings.general'), form_data) + + mock_schedule_bookmarks_without_favicons.assert_not_called() + + # No update scheduled when disabling favicons + form_data = self.create_profile_form_data({ + 'enable_favicons': False, + }) + + self.client.post(reverse('bookmarks:settings.general'), form_data) + + mock_schedule_bookmarks_without_favicons.assert_not_called() + + def test_refresh_favicons(self): + with patch.object(tasks, 'schedule_refresh_favicons') as mock_schedule_refresh_favicons: + form_data = { + 'refresh_favicons': '', + } + response = self.client.post(reverse('bookmarks:settings.general'), form_data) + html = response.content.decode() + + mock_schedule_refresh_favicons.assert_called_once() + self.assertInHTML(''' +

+ Scheduled favicon update. This may take a while... +

+ ''', html) + + def test_refresh_favicons_should_not_be_called_without_respective_form_action(self): + with patch.object(tasks, 'schedule_refresh_favicons') as mock_schedule_refresh_favicons: + form_data = { + } + response = self.client.post(reverse('bookmarks:settings.general'), form_data) + html = response.content.decode() + + mock_schedule_refresh_favicons.assert_not_called() + self.assertInHTML(''' +

+ Scheduled favicon update. This may take a while... +

+ ''', html, count=0) + + def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self): + profile = self.get_or_create_test_user().profile + profile.enable_favicons = True + profile.save() + + response = self.client.get(reverse('bookmarks:settings.general')) + html = response.content.decode() + + self.assertInHTML(''' + + ''', html, count=1) + + def test_refresh_favicons_should_not_be_visible_when_favicons_disabled_in_profile(self): + profile = self.get_or_create_test_user().profile + profile.enable_favicons = False + profile.save() + + response = self.client.get(reverse('bookmarks:settings.general')) + html = response.content.decode() + + self.assertInHTML(''' + + ''', html, count=0) + + @override_settings(LD_ENABLE_REFRESH_FAVICONS=False) + def test_refresh_favicons_should_not_be_visible_when_disabled(self): + profile = self.get_or_create_test_user().profile + profile.enable_favicons = True + profile.save() + + response = self.client.get(reverse('bookmarks:settings.general')) + html = response.content.decode() + + self.assertInHTML(''' + + ''', html, count=0) def test_about_shows_version_info(self): response = self.client.get(reverse('bookmarks:settings.general')) diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py index ffa213d..da9eda8 100644 --- a/bookmarks/views/settings.py +++ b/bookmarks/views/settings.py @@ -3,6 +3,7 @@ import time from functools import lru_cache 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.db.models import prefetch_related_objects @@ -13,7 +14,7 @@ from rest_framework.authtoken.models import Token from bookmarks.models import UserProfileForm, FeedToken from bookmarks.queries import query_bookmarks -from bookmarks.services import exporter +from bookmarks.services import exporter, tasks from bookmarks.services import importer logger = logging.getLogger(__name__) @@ -28,24 +29,48 @@ except Exception as exc: @login_required def general(request): - if request.method == 'POST': - form = UserProfileForm(request.POST, instance=request.user.profile) - if form.is_valid(): - form.save() - else: - form = UserProfileForm(instance=request.user.profile) - + profile_form = None + enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS + update_profile_success_message = None + refresh_favicons_success_message = None import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success') import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors') version_info = get_version_info(get_ttl_hash()) + + if request.method == 'POST': + if 'update_profile' in request.POST: + profile_form = update_profile(request) + update_profile_success_message = 'Profile updated' + if 'refresh_favicons' in request.POST: + tasks.schedule_refresh_favicons(request.user) + refresh_favicons_success_message = 'Scheduled favicon update. This may take a while...' + + if not profile_form: + profile_form = UserProfileForm(instance=request.user.profile) + return render(request, 'settings/general.html', { - 'form': form, + 'form': profile_form, + 'enable_refresh_favicons': enable_refresh_favicons, + 'update_profile_success_message': update_profile_success_message, + 'refresh_favicons_success_message': refresh_favicons_success_message, 'import_success_message': import_success_message, 'import_errors_message': import_errors_message, 'version_info': version_info, }) +def update_profile(request): + user = request.user + profile = user.profile + favicons_were_enabled = profile.enable_favicons + form = UserProfileForm(request.POST, instance=profile) + if form.is_valid(): + form.save() + if profile.enable_favicons and not favicons_were_enabled: + tasks.schedule_bookmarks_without_favicons(request.user) + 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): diff --git a/bootstrap.sh b/bootstrap.sh index e843e2e..3283a4a 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -5,6 +5,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}" # Create data folder if it does not exist mkdir -p data +# Create favicon folder if it does not exist +mkdir -p data/favicons # Run database migration python manage.py migrate diff --git a/docs/Options.md b/docs/Options.md index 008cb30..8809e30 100644 --- a/docs/Options.md +++ b/docs/Options.md @@ -149,3 +149,15 @@ Values: `Integer` | Default = None The port of the database server. Should use the default port if left empty, for example `5432` for PostgresSQL. + +### `LD_FAVICON_PROVIDER` + +Values: `String` | Default = `https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON` + +The favicon provider used for downloading icons if they are enabled in the user profile settings. +The default provider is a Google service that automatically detects the correct favicon for a website, and provides icons in consistent image format (PNG) and in a consistent image size. + +This setting allows to configure a custom provider in form of a URL. +When calling the provider with the URL of a website, it must return the image data for the favicon of that website. +The configured favicon provider URL must contain a `{url}` placeholder that will be replaced with the URL of the website for which to download the favicon. +See the default URL for an example. diff --git a/siteroot/settings/base.py b/siteroot/settings/base.py index e5cf817..a838044 100644 --- a/siteroot/settings/base.py +++ b/siteroot/settings/base.py @@ -140,6 +140,7 @@ STATICFILES_FINDERS = [ # Enable SASS processor to find custom folder for SCSS sources through static file finders STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'bookmarks', 'styles'), + os.path.join(BASE_DIR, 'data', 'favicons'), ] # REST framework @@ -222,3 +223,9 @@ else: DATABASES = { 'default': default_database } + +# Favicons +LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON' +LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER) +LD_FAVICON_FOLDER = os.path.join(BASE_DIR, 'data', 'favicons') +LD_ENABLE_REFRESH_FAVICONS = os.getenv('LD_ENABLE_REFRESH_FAVICONS', True) in (True, 'True', '1') diff --git a/uwsgi.ini b/uwsgi.ini index a15dcb6..7e9ab38 100644 --- a/uwsgi.ini +++ b/uwsgi.ini @@ -1,8 +1,8 @@ [uwsgi] -chdir = /etc/linkding module = siteroot.wsgi:application env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod static-map = /static=static +static-map = /static=data/favicons processes = 2 threads = 2 pidfile = /tmp/linkding.pid @@ -15,6 +15,7 @@ die-on-term = true if-env = LD_CONTEXT_PATH static-map = /%(_)static=static +static-map = /%(_)static=data/favicons endif = if-env = LD_REQUEST_TIMEOUT