Compare commits

..

18 Commits

Author SHA1 Message Date
Sascha Ißbrücker
794b6d8932 Bump version 2023-01-22 14:15:50 +01:00
Sascha Ißbrücker
6b4664117b Fix favicon being cleared by web archive snapshot task (#405) 2023-01-22 14:07:06 +01:00
Sascha Ißbrücker
621b497dc6 Add basic E2E test setup 2023-01-22 00:47:47 +01:00
Sascha Ißbrücker
4bb05f811b Update CHANGELOG.md 2023-01-21 17:15:28 +01:00
Sascha Ißbrücker
fb8e6b3b5f Bump version 2023-01-21 17:16:11 +01:00
Sascha Ißbrücker
814401be2e 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
2023-01-21 16:36:10 +01:00
Sascha Ißbrücker
4cb39fae99 Prefill form if URL is already bookmarked (#402)
* Prefill form from existing bookmark

* add bookmark check api tests
2023-01-20 22:44:10 +01:00
Sascha Ißbrücker
30da1880a5 Cache website metadata to avoid duplicate scraping (#401)
* Cache website metadata to avoid duplicate scraping

* fix test setup
2023-01-20 22:28:44 +01:00
McKenna Jones
da99b8b034 Add Health Check endpoint (#392)
* add simple health endpoint

* add curl and healthcheck to dockerfile

* convert to view

* add simple test

* Add unhealthy test

* Cleanup

* check for LD_SERVER_PORT env var in healthcheck def

* Revert changes to middlewares.py

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2023-01-20 22:26:58 +01:00
Sascha Ißbrücker
894625aa25 Update CHANGELOG.md 2023-01-20 22:23:32 +01:00
Sascha Ißbrücker
62d7fb5f63 Bump version 2023-01-20 21:28:51 +01:00
dependabot[bot]
fa2633147a Bump minimatch from 3.0.4 to 3.1.2 (#366)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.0.4 to 3.1.2.
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-20 21:18:55 +01:00
dependabot[bot]
ddf97b0a3f Bump certifi from 2022.6.15 to 2022.12.7 (#374)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.6.15 to 2022.12.7.
- [Release notes](https://github.com/certifi/python-certifi/releases)
- [Commits](https://github.com/certifi/python-certifi/compare/2022.06.15...2022.12.07)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-20 21:08:52 +01:00
dependabot[bot]
d3b4aa7602 Bump django from 4.1 to 4.1.2 (#391)
Bumps [django](https://github.com/django/django) from 4.1 to 4.1.2.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/4.1...4.1.2)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-20 21:04:30 +01:00
Sascha Ißbrücker
021d1cd673 Fix bookmark website metadata not being updated when URL changes (#400) 2023-01-20 20:59:09 +01:00
Sascha Ißbrücker
43d52642a6 Fix website loader test 2023-01-14 12:26:04 +01:00
Sascha Ißbrücker
4f9170c48d Improve website loader logging 2023-01-14 11:24:09 +01:00
Sascha Ißbrücker
313a0ee99f Update CHANGELOG.md 2023-01-12 21:34:36 +01:00
44 changed files with 1322 additions and 121 deletions

View File

@@ -3,22 +3,45 @@ name: linkding CI
on: [push] on: [push]
jobs: jobs:
run_tests: unit_tests:
name: Run Django Tests name: Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v1 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.10"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v2 uses: actions/setup-node@v3
with: with:
node-version: 14 node-version: 18
- name: Install Python dependencies
run: pip install -r requirements.txt
- name: Install Node dependencies - name: Install Node dependencies
run: npm install run: npm install
- name: Setup Python environment
run: pip install -r requirements.txt
- name: Run tests - name: Run tests
run: python manage.py test run: python manage.py test bookmarks.tests
e2e_tests:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install Node dependencies
run: npm install
- name: Setup Python environment
run: |
pip install -r requirements.txt
playwright install chromium
python manage.py compilescss
python manage.py collectstatic --ignore=*.scss
- name: Run tests
run: python manage.py test bookmarks.e2e

View File

@@ -1,5 +1,66 @@
# Changelog # Changelog
## v1.17.0 (21/01/2023)
### What's Changed
* Add Health Check endpoint by @mckennajones in https://github.com/sissbruecker/linkding/pull/392
* Cache website metadata to avoid duplicate scraping by @sissbruecker in https://github.com/sissbruecker/linkding/pull/401
* Prefill form if URL is already bookmarked by @sissbruecker in https://github.com/sissbruecker/linkding/pull/402
* Add option for showing bookmark favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/390
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.1...v1.17.0
---
## v1.16.1 (20/01/2023)
### What's Changed
* Fix bookmark website metadata not being updated when URL changes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/400
* Bump django from 4.1 to 4.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/391
* Bump certifi from 2022.6.15 to 2022.12.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/374
* Bump minimatch from 3.0.4 to 3.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/366
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.0...v1.16.1
---
## v1.16.0 (12/01/2023)
### What's Changed
* Add postgres as database engine by @tomamplius in https://github.com/sissbruecker/linkding/pull/388
* Gracefully stop docker container when it receives SIGTERM by @mckennajones in https://github.com/sissbruecker/linkding/pull/368
* Limit document size for website scraper by @sissbruecker in https://github.com/sissbruecker/linkding/pull/354
* Add error handling for checking latest version by @sissbruecker in https://github.com/sissbruecker/linkding/pull/360
* Trim website metadata title and description by @luca1197 in https://github.com/sissbruecker/linkding/pull/383
* Only show admin link for superusers by @AlexanderS in https://github.com/sissbruecker/linkding/pull/384
* Add apache reverse proxy documentation. by @jhauris in https://github.com/sissbruecker/linkding/pull/371
* Correct LD_ENABLE_AUTH_PROXY documentation by @jhauris in https://github.com/sissbruecker/linkding/pull/379
* Android HTTP shortcuts v3 by @kzshantonu in https://github.com/sissbruecker/linkding/pull/387
### New Contributors
* @jhauris made their first contribution in https://github.com/sissbruecker/linkding/pull/371
* @AlexanderS made their first contribution in https://github.com/sissbruecker/linkding/pull/384
* @mckennajones made their first contribution in https://github.com/sissbruecker/linkding/pull/368
* @tomamplius made their first contribution in https://github.com/sissbruecker/linkding/pull/388
* @luca1197 made their first contribution in https://github.com/sissbruecker/linkding/pull/383
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.1...v1.16.0
---
## v1.15.1 (05/10/2022)
### What's Changed
* Fix static file dir warning by @sissbruecker in https://github.com/sissbruecker/linkding/pull/350
* Add setting and documentation for fixing CSRF errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/349
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.0...v1.15.1
---
## v1.15.0 (11/09/2022) ## v1.15.0 (11/09/2022)
### What's Changed ### What's Changed

View File

@@ -34,7 +34,7 @@ RUN mkdir /opt/venv && \
FROM python:3.10.6-slim-buster as final FROM python:3.10.6-slim-buster as final
RUN apt-get update && apt-get -y install mime-support libpq-dev RUN apt-get update && apt-get -y install mime-support libpq-dev curl
WORKDIR /etc/linkding WORKDIR /etc/linkding
# copy prod dependencies # copy prod dependencies
COPY --from=prod-deps /opt/venv /opt/venv COPY --from=prod-deps /opt/venv /opt/venv
@@ -51,4 +51,8 @@ ENV PATH /opt/venv/bin:$PATH
RUN ["chmod", "g+w", "."] RUN ["chmod", "g+w", "."]
# Run bootstrap logic # Run bootstrap logic
RUN ["chmod", "+x", "./bootstrap.sh"] RUN ["chmod", "+x", "./bootstrap.sh"]
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/health || exit 1
CMD ["./bootstrap.sh"] CMD ["./bootstrap.sh"]

View File

@@ -1,4 +1,3 @@
from django.urls import reverse
from rest_framework import viewsets, mixins, status from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
@@ -7,8 +6,8 @@ from rest_framework.routers import DefaultRouter
from bookmarks import queries from bookmarks import queries
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
from bookmarks.models import Bookmark, BookmarkFilters, Tag, User from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader
from bookmarks.services.website_loader import load_website_metadata from bookmarks.services.website_loader import WebsiteMetadata
class BookmarkViewSet(viewsets.GenericViewSet, class BookmarkViewSet(viewsets.GenericViewSet,
@@ -68,15 +67,13 @@ class BookmarkViewSet(viewsets.GenericViewSet,
def check(self, request): def check(self, request):
url = request.GET.get('url') url = request.GET.get('url')
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first() bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
existing_bookmark_data = None existing_bookmark_data = self.get_serializer(bookmark).data if bookmark else None
if bookmark is not None: # Either return metadata from existing bookmark, or scrape from URL
existing_bookmark_data = { if bookmark:
'id': bookmark.id, metadata = WebsiteMetadata(url, bookmark.website_title, bookmark.website_description)
'edit_url': reverse('bookmarks:edit', args=[bookmark.id]) else:
} metadata = website_loader.load_website_metadata(url)
metadata = load_website_metadata(url)
return Response({ return Response({
'bookmark': existing_bookmark_data, 'bookmark': existing_bookmark_data,

View File

21
bookmarks/e2e/helpers.py Normal file
View File

@@ -0,0 +1,21 @@
from django.contrib.staticfiles.testing import LiveServerTestCase
from playwright.sync_api import BrowserContext
from bookmarks.tests.helpers import BookmarkFactoryMixin
class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.client.force_login(self.get_or_create_test_user())
self.cookie = self.client.cookies['sessionid']
def setup_browser(self, playwright) -> BrowserContext:
browser = playwright.chromium.launch(headless=True)
context = browser.new_context()
context.add_cookies([{
'name': 'sessionid',
'value': self.cookie.value,
'domain': self.live_server_url.replace('http:', ''),
'path': '/'
}])
return context

View File

@@ -0,0 +1,51 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
def test_create_should_check_for_existing_bookmark(self):
existing_bookmark = self.setup_bookmark(title='Existing title',
description='Existing description',
tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2')],
website_title='Existing website title',
website_description='Existing website description',
unread=True)
tag_names = ' '.join(existing_bookmark.tag_names)
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:new'))
# Enter bookmarked URL
page.get_by_label('URL').fill(existing_bookmark.url)
# Already bookmarked hint should be visible
page.get_by_text('This URL is already bookmarked.').wait_for(timeout=2000)
# Form should be pre-filled with data from existing bookmark
self.assertEqual(existing_bookmark.title, page.get_by_label('Title').input_value())
self.assertEqual(existing_bookmark.description, page.get_by_label('Description').input_value())
self.assertEqual(existing_bookmark.website_title, page.get_by_label('Title').get_attribute('placeholder'))
self.assertEqual(existing_bookmark.website_description,
page.get_by_label('Description').get_attribute('placeholder'))
self.assertEqual(tag_names, page.get_by_label('Tags').input_value())
self.assertTrue(tag_names, page.get_by_label('Mark as unread').is_checked())
# Enter non-bookmarked URL
page.get_by_label('URL').fill('https://example.com/unknown')
# Already bookmarked hint should be hidden
page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden', timeout=2000)
browser.close()
def test_edit_should_not_check_for_existing_bookmark(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:edit', args=[bookmark.id]))
page.wait_for_timeout(timeout=1000)
page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden')

View File

@@ -0,0 +1,30 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
def test_focus_search(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:index'))
page.press('body', 's')
expect(page.get_by_placeholder('Search for words or #tags')).to_be_focused()
browser.close()
def test_add_bookmark(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse('bookmarks:index'))
page.press('body', 'n')
expect(page).to_have_url(self.live_server_url + reverse('bookmarks:new'))
browser.close()

View File

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

View File

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

View File

@@ -53,6 +53,7 @@ class Bookmark(models.Model):
website_title = models.CharField(max_length=512, blank=True, null=True) website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True) website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=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) unread = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False) is_archived = models.BooleanField(default=False)
shared = 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, web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
default=WEB_ARCHIVE_INTEGRATION_DISABLED) default=WEB_ARCHIVE_INTEGRATION_DISABLED)
enable_sharing = models.BooleanField(default=False, null=False) enable_sharing = models.BooleanField(default=False, null=False)
enable_favicons = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
class Meta: class Meta:
model = UserProfile 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()) @receiver(post_save, sender=get_user_model())

View File

@@ -30,6 +30,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
bookmark.save() bookmark.save()
# Create snapshot on web archive # Create snapshot on web archive
tasks.create_web_archive_snapshot(current_user, bookmark, False) tasks.create_web_archive_snapshot(current_user, bookmark, False)
# Load favicon
tasks.load_favicon(current_user, bookmark)
return bookmark return bookmark
@@ -43,11 +45,15 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
# Update dates # Update dates
bookmark.date_modified = timezone.now() bookmark.date_modified = timezone.now()
bookmark.save() bookmark.save()
# Update favicon
tasks.load_favicon(current_user, bookmark)
if has_url_changed: if has_url_changed:
# Update web archive snapshot, if URL changed # Update web archive snapshot, if URL changed
tasks.create_web_archive_snapshot(current_user, bookmark, True) tasks.create_web_archive_snapshot(current_user, bookmark, True)
# Only update website metadata if URL changed # Only update website metadata if URL changed
_update_website_metadata(bookmark) _update_website_metadata(bookmark)
bookmark.save()
return bookmark return bookmark

View File

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

View File

@@ -74,6 +74,8 @@ def import_netscape_html(html: str, user: User):
# Create snapshots for newly imported bookmarks # Create snapshots for newly imported bookmarks
tasks.schedule_bookmarks_without_snapshots(user) tasks.schedule_bookmarks_without_snapshots(user)
# Load favicons for newly imported bookmarks
tasks.schedule_bookmarks_without_favicons(user)
end = timezone.now() end = timezone.now()
logger.debug(f'Import duration: {end - import_start}') logger.debug(f'Import duration: {end - import_start}')

View File

@@ -2,6 +2,7 @@ import logging
import waybackpy import waybackpy
from background_task import background from background_task import background
from background_task.models import Task
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import User from django.contrib.auth.models import User
@@ -9,6 +10,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord
import bookmarks.services.wayback import bookmarks.services.wayback
from bookmarks.models import Bookmark, UserProfile from bookmarks.models import Bookmark, UserProfile
from bookmarks.services import favicon_loader
from bookmarks.services.website_loader import DEFAULT_USER_AGENT from bookmarks.services.website_loader import DEFAULT_USER_AGENT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,7 +37,7 @@ def _load_newest_snapshot(bookmark: Bookmark):
if existing_snapshot: if existing_snapshot:
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
bookmark.save() bookmark.save(update_fields=['web_archive_snapshot_url'])
logger.info(f'Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}') logger.info(f'Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}')
except NoCDXRecordFound: except NoCDXRecordFound:
@@ -49,7 +51,7 @@ def _create_snapshot(bookmark: Bookmark):
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT, max_tries=1) archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT, max_tries=1)
archive.save() archive.save()
bookmark.web_archive_snapshot_url = archive.archive_url bookmark.web_archive_snapshot_url = archive.archive_url
bookmark.save() bookmark.save(update_fields=['web_archive_snapshot_url'])
logger.info(f'Successfully created new snapshot for bookmark:. url={bookmark.url}') logger.info(f'Successfully created new snapshot for bookmark:. url={bookmark.url}')
@@ -72,7 +74,8 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
logger.error( logger.error(
f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}') f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}')
except WaybackError as error: 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 the newest snapshot as fallback
_load_newest_snapshot(bookmark) _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 # 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 # new ones when processing bookmarks in bulk
_load_web_archive_snapshot_task(bookmark.id) _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(update_fields=['favicon_file'])
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)

View File

@@ -1,9 +1,11 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from charset_normalizer import from_bytes from charset_normalizer import from_bytes
from django.utils import timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,16 +24,27 @@ class WebsiteMetadata:
} }
# Caching metadata avoids scraping again when saving bookmarks, in case the
# metadata was already scraped to show preview values in the bookmark form
@lru_cache(maxsize=10)
def load_website_metadata(url: str): def load_website_metadata(url: str):
title = None title = None
description = None description = None
try: try:
start = timezone.now()
page_text = load_page(url) page_text = load_page(url)
end = timezone.now()
logger.debug(f'Load duration: {end - start}')
start = timezone.now()
soup = BeautifulSoup(page_text, 'html.parser') soup = BeautifulSoup(page_text, 'html.parser')
title = soup.title.string.strip() if soup.title is not None else None title = soup.title.string.strip() if soup.title is not None else None
description_tag = soup.find('meta', attrs={'name': 'description'}) description_tag = soup.find('meta', attrs={'name': 'description'})
description = description = description_tag['content'].strip() if description_tag and description_tag['content'] else None description = description = description_tag['content'].strip() if description_tag and description_tag[
'content'] else None
end = timezone.now()
logger.debug(f'Parsing duration: {end - start}')
finally: finally:
return WebsiteMetadata(url=url, title=title, description=description) return WebsiteMetadata(url=url, title=title, description=description)
@@ -44,15 +57,19 @@ def load_page(url: str):
headers = fake_request_headers() headers = fake_request_headers()
size = 0 size = 0
content = None content = None
iteration = 0
# Use with to ensure request gets closed even if it's only read partially # Use with to ensure request gets closed even if it's only read partially
with requests.get(url, timeout=10, headers=headers, stream=True) as r: with requests.get(url, timeout=10, headers=headers, stream=True) as r:
for chunk in r.iter_content(chunk_size=CHUNK_SIZE): for chunk in r.iter_content(chunk_size=CHUNK_SIZE):
size += len(chunk) size += len(chunk)
iteration = iteration + 1
if content is None: if content is None:
content = chunk content = chunk
else: else:
content = content + chunk content = content + chunk
logger.debug(f'Loaded chunk (iteration={iteration}, total={size / 1024})')
# Stop reading if we have parsed end of head tag # Stop reading if we have parsed end of head tag
if '</head>'.encode('utf-8') in content: if '</head>'.encode('utf-8') in content:
logger.debug(f'Found closing head tag after {size} bytes') logger.debug(f'Found closing head tag after {size} bytes')
@@ -61,6 +78,8 @@ def load_page(url: str):
if size > MAX_CONTENT_LIMIT: if size > MAX_CONTENT_LIMIT:
logger.debug(f'Cancel reading document after {size} bytes') logger.debug(f'Cancel reading document after {size} bytes')
break break
if hasattr(r, '_content_consumed'):
logger.debug(f'Request consumed: {r._content_consumed}')
# Use charset_normalizer to determine encoding that best matches the response content # Use charset_normalizer to determine encoding that best matches the response content
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead # Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead

View File

@@ -58,6 +58,12 @@ ul.bookmark-list {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.title img {
width: 16px;
height: 16px;
vertical-align: text-top;
}
.description { .description {
color: $gray-color-dark; color: $gray-color-dark;

View File

@@ -1,3 +1,4 @@
{% load static %}
{% load shared %} {% load shared %}
{% load pagination %} {% load pagination %}
{% htmlmin %} {% htmlmin %}
@@ -11,6 +12,9 @@
<div class="title"> <div class="title">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener" <a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="{% if bookmark.unread %}text-italic{% endif %}"> class="{% if bookmark.unread %}text-italic{% endif %}">
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
<img src="{% static bookmark.favicon_file %}" alt="">
{% endif %}
{{ bookmark.resolved_title }} {{ bookmark.resolved_title }}
</a> </a>
</div> </div>

View File

@@ -15,8 +15,8 @@
</div> </div>
{% endif %} {% endif %}
<div class="form-input-hint bookmark-exists"> <div class="form-input-hint bookmark-exists">
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark This URL is already bookmarked.
by saving this form. The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -128,6 +128,9 @@
const urlInput = document.getElementById('{{ form.url.id_for_label }}'); const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.id_for_label }}'); const titleInput = document.getElementById('{{ form.title.id_for_label }}');
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}'); const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
const websiteTitleInput = document.getElementById('{{ form.website_title.id_for_label }}'); const websiteTitleInput = document.getElementById('{{ form.website_title.id_for_label }}');
const websiteDescriptionInput = document.getElementById('{{ form.website_description.id_for_label }}'); const websiteDescriptionInput = document.getElementById('{{ form.website_description.id_for_label }}');
const editedBookmarkId = {{ bookmark_id }}; const editedBookmarkId = {{ bookmark_id }};
@@ -145,6 +148,14 @@
} }
} }
function updateInput(input, value) {
input.value = value;
}
function updateCheckbox(input, value) {
input.checked = value;
}
function checkUrl() { function checkUrl() {
toggleLoadingIcon(titleInput, true); toggleLoadingIcon(titleInput, true);
toggleLoadingIcon(descriptionInput, true); toggleLoadingIcon(descriptionInput, true);
@@ -162,13 +173,17 @@
toggleLoadingIcon(titleInput, false); toggleLoadingIcon(titleInput, false);
toggleLoadingIcon(descriptionInput, false); toggleLoadingIcon(descriptionInput, false);
// Display hint if URL is already bookmarked // Prefill form and display hint if URL is already bookmarked
const existingBookmark = data.bookmark;
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists'); const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a');
if (data.bookmark && data.bookmark.id !== editedBookmarkId) { if (existingBookmark && !editedBookmarkId) {
bookmarkExistsHint.style['display'] = 'block'; bookmarkExistsHint.style['display'] = 'block';
editExistingBookmarkLink.href = data.bookmark.edit_url; updateInput(titleInput, existingBookmark.title);
updateInput(descriptionInput, existingBookmark.description);
updateInput(tagsInput, existingBookmark.tag_names.join(" "));
updateCheckbox(unreadCheckbox, existingBookmark.unread);
updateCheckbox(sharedCheckbox, existingBookmark.shared);
} else { } else {
bookmarkExistsHint.style['display'] = 'none'; bookmarkExistsHint.style['display'] = 'none';
} }

View File

@@ -36,6 +36,30 @@
Whether to open bookmarks a new page or in the same page. Whether to open bookmarks a new page or in the same page.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
{{ form.enable_favicons }}
<i class="form-icon"></i> Enable Favicons
</label>
<div class="form-input-hint">
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
By default, this feature uses a <b>Google service</b> to download favicons.
If you don't want to use this service, check the <a
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md" target="_blank">options
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 %}
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
{% endif %}
{% if refresh_favicons_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ refresh_favicons_success_message }}
</p>
</div>
{% endif %}
</div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive <label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
integration</label> integration</label>
@@ -61,7 +85,14 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="submit" value="Save" class="btn btn-primary mt-2"> <input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
{% if update_profile_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ update_profile_success_message }}
</p>
</div>
{% endif %}
</div> </div>
</form> </form>
</section> </section>

View File

@@ -33,6 +33,7 @@ class BookmarkFactoryMixin:
website_title: str = '', website_title: str = '',
website_description: str = '', website_description: str = '',
web_archive_snapshot_url: str = '', web_archive_snapshot_url: str = '',
favicon_file: str = '',
): ):
if not title: if not title:
title = get_random_string(length=32) title = get_random_string(length=32)
@@ -56,6 +57,7 @@ class BookmarkFactoryMixin:
unread=unread, unread=unread,
shared=shared, shared=shared,
web_archive_snapshot_url=web_archive_snapshot_url, web_archive_snapshot_url=web_archive_snapshot_url,
favicon_file=favicon_file,
) )
bookmark.save() bookmark.save()
for tag in tags: for tag in tags:

View File

@@ -1,4 +1,6 @@
import urllib.parse
from collections import OrderedDict from collections import OrderedDict
from unittest.mock import patch
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.urls import reverse from django.urls import reverse
@@ -6,6 +8,8 @@ from rest_framework import status
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from bookmarks.models import Bookmark from bookmarks.models import Bookmark
from bookmarks.services import website_loader
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
@@ -353,6 +357,66 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id) bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
self.assertFalse(bookmark.is_archived) self.assertFalse(bookmark.is_archived)
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
bookmark_data = response.data['bookmark']
self.assertIsNone(bookmark_data)
def test_check_returns_scraped_metadata_if_url_is_not_bookmarked(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
'https://example.com',
'Scraped metadata',
'Scraped description'
)
mock_load_website_metadata.return_value = expected_metadata
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
metadata = response.data['metadata']
self.assertIsNotNone(metadata)
self.assertIsNotNone(expected_metadata.url, metadata['url'])
self.assertIsNotNone(expected_metadata.title, metadata['title'])
self.assertIsNotNone(expected_metadata.description, metadata['description'])
def test_check_returns_bookmark_if_url_is_bookmarked(self):
bookmark = self.setup_bookmark(url='https://example.com',
title='Example title',
description='Example description')
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
bookmark_data = response.data['bookmark']
self.assertIsNotNone(bookmark_data)
self.assertEqual(bookmark.id, bookmark_data['id'])
self.assertEqual(bookmark.url, bookmark_data['url'])
self.assertEqual(bookmark.title, bookmark_data['title'])
self.assertEqual(bookmark.description, bookmark_data['description'])
def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
bookmark = self.setup_bookmark(url='https://example.com',
website_title='Existing title',
website_description='Existing description')
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
metadata = response.data['metadata']
mock_load_website_metadata.assert_not_called()
self.assertIsNotNone(metadata)
self.assertIsNotNone(bookmark.url, metadata['url'])
self.assertIsNotNone(bookmark.website_title, metadata['title'])
self.assertIsNotNone(bookmark.website_description, metadata['description'])
def test_can_only_access_own_bookmarks(self): def test_can_only_access_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
inaccessible_bookmark = self.setup_bookmark(user=other_user) inaccessible_bookmark = self.setup_bookmark(user=other_user)
@@ -396,3 +460,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_shared_bookmark.id]) url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_shared_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus(inaccessible_bookmark.url)
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
self.assertIsNone(response.data['bookmark'])

View File

@@ -79,6 +79,17 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
</span> </span>
''', html, count=count) ''', 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'''
<img src="/static/{bookmark.favicon_file}" alt="">
''', html, count=count)
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str: def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
@@ -211,3 +222,33 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a> <a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span> </span>
''', html) ''', 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)

View File

@@ -5,11 +5,12 @@ from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, Tag from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \ from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.services import website_loader from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.services import tasks
User = get_user_model() User = get_user_model()
@@ -19,6 +20,27 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None: def setUp(self) -> None:
self.get_or_create_test_user() self.get_or_create_test_user()
def test_create_should_update_website_metadata(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
'https://example.com',
'Website title',
'Website description'
)
mock_load_website_metadata.return_value = expected_metadata
bookmark_data = Bookmark(url='https://example.com',
title='Updated Title',
description='Updated description',
unread=True,
shared=True,
is_archived=True)
created_bookmark = create_bookmark(bookmark_data, '', self.get_or_create_test_user())
created_bookmark.refresh_from_db()
self.assertEqual(expected_metadata.title, created_bookmark.website_title)
self.assertEqual(expected_metadata.description, created_bookmark.website_description)
def test_create_should_update_existing_bookmark_with_same_url(self): def test_create_should_update_existing_bookmark_with_same_url(self):
original_bookmark = self.setup_bookmark(url='https://example.com', unread=False, shared=False) original_bookmark = self.setup_bookmark(url='https://example.com', unread=False, shared=False)
bookmark_data = Bookmark(url='https://example.com', bookmark_data = Bookmark(url='https://example.com',
@@ -45,6 +67,13 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False) 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): 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: with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -63,11 +92,21 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def test_update_should_update_website_metadata_if_url_did_change(self): def test_update_should_update_website_metadata_if_url_did_change(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata: with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
'https://example.com/updated',
'Updated website title',
'Updated website description'
)
mock_load_website_metadata.return_value = expected_metadata
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
bookmark.url = 'https://example.com/updated' bookmark.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user) update_bookmark(bookmark, 'tag1,tag2', self.user)
bookmark.refresh_from_db()
mock_load_website_metadata.assert_called_once() mock_load_website_metadata.assert_called_once()
self.assertEqual(expected_metadata.title, bookmark.website_title)
self.assertEqual(expected_metadata.description, bookmark.website_description)
def test_update_should_not_update_website_metadata_if_url_did_not_change(self): def test_update_should_not_update_website_metadata_if_url_did_not_change(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata: with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
@@ -77,6 +116,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_load_website_metadata.assert_not_called() 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): def test_archive_bookmark(self):
bookmark = Bookmark( bookmark = Bookmark(
url='https://example.com', url='https://example.com',

View File

@@ -1,6 +1,7 @@
import datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
from unittest.mock import patch from typing import Any
from unittest import mock
import waybackpy import waybackpy
from background_task.models import Task from background_task.models import Task
@@ -8,21 +9,21 @@ from django.contrib.auth.models import User
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from waybackpy.exceptions import WaybackError from waybackpy.exceptions import WaybackError
import bookmarks.services.favicon_loader
import bookmarks.services.wayback import bookmarks.services.wayback
from bookmarks.models import UserProfile from bookmarks.models import UserProfile
from bookmarks.services import tasks from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class MockWaybackMachineSaveAPI: def create_wayback_machine_save_api_mock(archive_url: str = 'https://example.com/created_snapshot',
def __init__(self, archive_url: str = 'https://example.com/created_snapshot', fail_on_save: bool = False): fail_on_save: bool = False):
self.archive_url = archive_url mock_api = mock.Mock(archive_url=archive_url)
self.fail_on_save = fail_on_save
def save(self): if fail_on_save:
if self.fail_on_save: mock_api.save.side_effect = WaybackError
raise WaybackError
return self return mock_api
@dataclass @dataclass
@@ -31,21 +32,18 @@ class MockCdxSnapshot:
datetime_timestamp: datetime.datetime datetime_timestamp: datetime.datetime
class MockWaybackMachineCDXServerAPI: def create_cdx_server_api_mock(archive_url: str | None = 'https://example.com/newest_snapshot',
def __init__(self,
archive_url: str = 'https://example.com/newest_snapshot',
has_no_snapshot=False,
fail_loading_snapshot=False): fail_loading_snapshot=False):
self.archive_url = archive_url mock_api = mock.Mock()
self.has_no_snapshot = has_no_snapshot
self.fail_loading_snapshot = fail_loading_snapshot
def newest(self): if fail_loading_snapshot:
if self.has_no_snapshot: mock_api.newest.side_effect = WaybackError
return None elif archive_url:
if self.fail_loading_snapshot: mock_api.newest.return_value = MockCdxSnapshot(archive_url, datetime.datetime.now())
raise WaybackError else:
return MockCdxSnapshot(self.archive_url, datetime.datetime.now()) mock_api.newest.return_value = None
return mock_api
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin): class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
@@ -53,49 +51,55 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self): def setUp(self):
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
user.profile.enable_favicons = True
user.profile.save() user.profile.save()
@disable_logging @disable_logging
def run_pending_task(self, task_function): def run_pending_task(self, task_function: Any):
func = getattr(task_function, 'task_function', None) func = getattr(task_function, 'task_function', None)
task = Task.objects.all()[0] task = Task.objects.all()[0]
self.assertEqual(task_function.name, task.task_name)
args, kwargs = task.params() args, kwargs = task.params()
func(*args, **kwargs) func(*args, **kwargs)
task.delete() task.delete()
@disable_logging @disable_logging
def run_all_pending_tasks(self, task_function): def run_all_pending_tasks(self, task_function: Any):
func = getattr(task_function, 'task_function', None) func = getattr(task_function, 'task_function', None)
tasks = Task.objects.all() tasks = Task.objects.all()
for task in tasks: for task in tasks:
self.assertEqual(task_function.name, task.task_name)
args, kwargs = task.params() args, kwargs = task.params()
func(*args, **kwargs) func(*args, **kwargs)
task.delete() task.delete()
def test_create_web_archive_snapshot_should_update_snapshot_url(self): def test_create_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
mock_save_api = create_wayback_machine_save_api_mock()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()): with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db() bookmark.refresh_from_db()
mock_save_api.save.assert_called_once()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot') self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot')
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self): def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with patch.object(waybackpy, 'WaybackMachineSaveAPI', mock_save_api = create_wayback_machine_save_api_mock()
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
tasks._create_web_archive_snapshot_task(123, False) tasks._create_web_archive_snapshot_task(123, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
mock_save_api.assert_not_called() mock_save_api.save.assert_not_called()
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self): def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com') bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
mock_save_api = create_wayback_machine_save_api_mock()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
@@ -103,9 +107,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_force_update_snapshot(self): def test_create_web_archive_snapshot_should_force_update_snapshot(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com') bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
mock_save_api = create_wayback_machine_save_api_mock(archive_url='https://other.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
return_value=MockWaybackMachineSaveAPI('https://other.com')):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db() bookmark.refresh_from_db()
@@ -114,24 +118,27 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self): def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
mock_cdx_api = create_cdx_server_api_mock()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', return_value=mock_cdx_api):
return_value=MockWaybackMachineCDXServerAPI()):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db() bookmark.refresh_from_db()
mock_cdx_api.newest.assert_called_once()
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url) self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self): def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', return_value=mock_cdx_api):
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
@@ -140,51 +147,78 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self): def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', return_value=mock_cdx_api):
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertEqual('', bookmark.web_archive_snapshot_url) self.assertEqual('', bookmark.web_archive_snapshot_url)
def test_create_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
bookmark = self.setup_bookmark()
mock_save_api = create_wayback_machine_save_api_mock()
# update bookmark during API call to check that saving
# the snapshot does not overwrite updated bookmark data
def mock_save_impl():
bookmark.title = 'Updated title'
bookmark.save()
mock_save_api.save.side_effect = mock_save_impl
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=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)
bookmark.refresh_from_db()
self.assertEqual(bookmark.title, 'Updated title')
self.assertEqual('https://example.com/created_snapshot', bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_update_snapshot_url(self): def test_load_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()): return_value=mock_cdx_api):
tasks._load_web_archive_snapshot_task(bookmark.id) tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task) self.run_pending_task(tasks._load_web_archive_snapshot_task)
bookmark.refresh_from_db() bookmark.refresh_from_db()
mock_cdx_api.newest.assert_called_once()
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url) self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self): def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', mock_cdx_api = create_cdx_server_api_mock()
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_api):
tasks._load_web_archive_snapshot_task(123) tasks._load_web_archive_snapshot_task(123)
self.run_pending_task(tasks._load_web_archive_snapshot_task) self.run_pending_task(tasks._load_web_archive_snapshot_task)
mock_cdx_api.assert_not_called() mock_cdx_api.newest.assert_not_called()
def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self): def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com') bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
mock_cdx_api = create_cdx_server_api_mock()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api: return_value=mock_cdx_api):
tasks._load_web_archive_snapshot_task(bookmark.id) tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task) self.run_pending_task(tasks._load_web_archive_snapshot_task)
mock_cdx_api.assert_not_called() mock_cdx_api.newest.assert_not_called()
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self): def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)): return_value=mock_cdx_api):
tasks._load_web_archive_snapshot_task(bookmark.id) tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task) self.run_pending_task(tasks._load_web_archive_snapshot_task)
@@ -192,14 +226,37 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_load_web_archive_snapshot_should_handle_wayback_errors(self): def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)): return_value=mock_cdx_api):
tasks._load_web_archive_snapshot_task(bookmark.id) tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task) self.run_pending_task(tasks._load_web_archive_snapshot_task)
self.assertEqual('', bookmark.web_archive_snapshot_url) self.assertEqual('', bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock()
# update bookmark during API call to check that saving
# the snapshot does not overwrite updated bookmark data
def mock_newest_impl():
bookmark.title = 'Updated title'
bookmark.save()
return mock.DEFAULT
mock_cdx_api.newest.side_effect = mock_newest_impl
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_api):
tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task)
bookmark.refresh_from_db()
self.assertEqual('Updated title', bookmark.title)
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True) @override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self): def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@@ -262,3 +319,177 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks.schedule_bookmarks_without_snapshots(self.user) tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(Task.objects.count(), 0) 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()
def test_load_favicon_should_not_save_stale_bookmark_data(self):
bookmark = self.setup_bookmark()
# update bookmark during API call to check that saving
# the favicon does not overwrite updated bookmark data
def mock_load_favicon_impl(url):
bookmark.title = 'Updated title'
bookmark.save()
return 'https_example_com.png'
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
mock_load_favicon.side_effect = mock_load_favicon_impl
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.title, 'Updated title')
self.assertEqual(bookmark.favicon_file, 'https_example_com.png')
@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)

View File

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

View File

@@ -0,0 +1,36 @@
from unittest.mock import patch
from django.db import connections
from django.test import TestCase
from bookmarks.views.settings import app_version
class HealthViewTestCase(TestCase):
def test_health_healthy(self):
response = self.client.get("/health")
self.assertEqual(response.status_code, 200)
response_body = response.json()
expected_body = {
'version': app_version,
'status': 'healthy'
}
self.assertDictEqual(response_body, expected_body)
def test_health_unhealhty(self):
with patch.object(connections['default'], 'ensure_connection') as mock_ensure_connection:
mock_ensure_connection.side_effect = Exception('Connection error')
response = self.client.get("/health")
self.assertEqual(response.status_code, 500)
response_body = response.json()
expected_body = {
'version': app_version,
'status': 'unhealthy'
}
self.assertDictEqual(response_body, expected_body)

View File

@@ -262,3 +262,12 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
import_netscape_html(test_html, user) import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(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)

View File

@@ -1,12 +1,13 @@
import random import random
from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch, Mock from unittest.mock import patch, Mock
import requests import requests
from django.test import TestCase, override_settings
from django.urls import reverse
from requests import RequestException from requests import RequestException
from bookmarks.models import UserProfile from bookmarks.models import UserProfile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info 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() user = self.get_or_create_test_user()
self.client.force_login(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): def test_should_render_successfully(self):
response = self.client.get(reverse('bookmarks:settings.general')) 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')) self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general'))
def test_should_save_profile(self): def test_update_profile(self):
form_data = { form_data = {
'update_profile': '',
'theme': UserProfile.THEME_DARK, 'theme': UserProfile.THEME_DARK,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN, 'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF, 'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED, 'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
'enable_sharing': True, 'enable_sharing': True,
'enable_favicons': True,
} }
response = self.client.post(reverse('bookmarks:settings.general'), form_data) response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
self.user.profile.refresh_from_db() 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.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.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_sharing, form_data['enable_sharing'])
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
self.assertInHTML('''
<p class="form-input-hint">Profile updated</p>
''', 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('''
<p class="form-input-hint">Profile updated</p>
''', 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('''
<p class="form-input-hint">
Scheduled favicon update. This may take a while...
</p>
''', 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('''
<p class="form-input-hint">
Scheduled favicon update. This may take a while...
</p>
''', 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('''
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
''', 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('''
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
''', 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('''
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
''', html, count=0)
def test_about_shows_version_info(self): def test_about_shows_version_info(self):
response = self.client.get(reverse('bookmarks:settings.general')) response = self.client.get(reverse('bookmarks:settings.general'))

View File

@@ -25,6 +25,10 @@ class MockStreamingResponse:
class WebsiteLoaderTestCase(TestCase): class WebsiteLoaderTestCase(TestCase):
def setUp(self):
# clear cached metadata before test run
website_loader.load_website_metadata.cache_clear()
def render_html_document(self, title, description): def render_html_document(self, title, description):
return f''' return f'''
<!DOCTYPE html> <!DOCTYPE html>

View File

@@ -31,4 +31,6 @@ urlpatterns = [
# Feeds # Feeds
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'), path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'), path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'),
# Health check
path('health', views.health, name='health')
] ]

View File

@@ -1,3 +1,4 @@
from .bookmarks import * from .bookmarks import *
from .settings import * from .settings import *
from .toasts import * from .toasts import *
from .health import health

20
bookmarks/views/health.py Normal file
View File

@@ -0,0 +1,20 @@
from django.db import connections
from django.http import JsonResponse
from bookmarks.views.settings import app_version
def health(request):
code = 200
response = {
'version': app_version,
'status': 'healthy'
}
try:
connections['default'].ensure_connection()
except Exception:
response['status'] = 'unhealthy'
code = 500
return JsonResponse(response, status=code)

View File

@@ -3,6 +3,7 @@ import time
from functools import lru_cache from functools import lru_cache
import requests import requests
from django.conf import settings as django_settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import prefetch_related_objects 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.models import UserProfileForm, FeedToken
from bookmarks.queries import query_bookmarks from bookmarks.queries import query_bookmarks
from bookmarks.services import exporter from bookmarks.services import exporter, tasks
from bookmarks.services import importer from bookmarks.services import importer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,24 +29,48 @@ except Exception as exc:
@login_required @login_required
def general(request): def general(request):
if request.method == 'POST': profile_form = None
form = UserProfileForm(request.POST, instance=request.user.profile) enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
if form.is_valid(): update_profile_success_message = None
form.save() refresh_favicons_success_message = None
else:
form = UserProfileForm(instance=request.user.profile)
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success') 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') import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
version_info = get_version_info(get_ttl_hash()) 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', { 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_success_message': import_success_message,
'import_errors_message': import_errors_message, 'import_errors_message': import_errors_message,
'version_info': version_info, '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 # Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_version_info(ttl_hash=None): def get_version_info(ttl_hash=None):

View File

@@ -5,6 +5,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
# Create data folder if it does not exist # Create data folder if it does not exist
mkdir -p data mkdir -p data
# Create favicon folder if it does not exist
mkdir -p data/favicons
# Run database migration # Run database migration
python manage.py migrate python manage.py migrate

View File

@@ -149,3 +149,15 @@ Values: `Integer` | Default = None
The port of the database server. The port of the database server.
Should use the default port if left empty, for example `5432` for PostgresSQL. 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.

16
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "linkding", "name": "linkding",
"version": "1.11.1", "version": "1.16.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "linkding", "name": "linkding",
"version": "1.11.1", "version": "1.16.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@rollup/plugin-commonjs": "^21.0.2", "@rollup/plugin-commonjs": "^21.0.2",
@@ -435,9 +435,9 @@
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.0.4", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@@ -995,9 +995,9 @@
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
}, },
"minimatch": { "minimatch": {
"version": "3.0.4", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }

View File

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

View File

@@ -1,10 +1,10 @@
asgiref==3.5.2 asgiref==3.5.2
beautifulsoup4==4.11.1 beautifulsoup4==4.11.1
certifi==2022.6.15 certifi==2022.12.7
charset-normalizer==2.1.1 charset-normalizer==2.1.1
click==8.1.3 click==8.1.3
confusable-homoglyphs==3.2.0 confusable-homoglyphs==3.2.0
Django==4.1 Django==4.1.2
django-generate-secret-key==1.0.2 django-generate-secret-key==1.0.2
django-registration==3.3 django-registration==3.3
django-sass-processor==1.2.1 django-sass-processor==1.2.1

View File

@@ -1,11 +1,11 @@
asgiref==3.5.2 asgiref==3.5.2
beautifulsoup4==4.11.1 beautifulsoup4==4.11.1
certifi==2022.6.15 certifi==2022.12.7
charset-normalizer==2.1.1 charset-normalizer==2.1.1
click==8.1.3 click==8.1.3
confusable-homoglyphs==3.2.0 confusable-homoglyphs==3.2.0
coverage==5.5 coverage==5.5
Django==4.1 Django==4.1.2
django-appconf==1.0.5 django-appconf==1.0.5
django-compressor==4.1 django-compressor==4.1
django-debug-toolbar==3.6.0 django-debug-toolbar==3.6.0
@@ -15,9 +15,12 @@ django-sass-processor==1.2.1
django-widget-tweaks==1.4.12 django-widget-tweaks==1.4.12
django4-background-tasks==1.2.7 django4-background-tasks==1.2.7
djangorestframework==3.13.1 djangorestframework==3.13.1
greenlet==2.0.1
idna==3.3 idna==3.3
libsass==0.21.0 libsass==0.21.0
playwright==1.29.1
psycopg2-binary==2.9.5 psycopg2-binary==2.9.5
pyee==9.0.4
python-dateutil==2.8.2 python-dateutil==2.8.2
pytz==2022.2.1 pytz==2022.2.1
rcssmin==1.1.0 rcssmin==1.1.0

View File

@@ -140,6 +140,7 @@ STATICFILES_FINDERS = [
# Enable SASS processor to find custom folder for SCSS sources through static file finders # Enable SASS processor to find custom folder for SCSS sources through static file finders
STATICFILES_DIRS = [ STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'bookmarks', 'styles'), os.path.join(BASE_DIR, 'bookmarks', 'styles'),
os.path.join(BASE_DIR, 'data', 'favicons'),
] ]
# REST framework # REST framework
@@ -222,3 +223,9 @@ else:
DATABASES = { DATABASES = {
'default': default_database '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')

View File

@@ -25,7 +25,7 @@ LOGGING = {
'disable_existing_loggers': False, 'disable_existing_loggers': False,
'formatters': { 'formatters': {
'simple': { 'simple': {
'format': '{levelname} {message}', 'format': '{levelname} {asctime} {module}: {message}',
'style': '{', 'style': '{',
}, },
}, },

View File

@@ -1,8 +1,8 @@
[uwsgi] [uwsgi]
chdir = /etc/linkding
module = siteroot.wsgi:application module = siteroot.wsgi:application
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
static-map = /static=static static-map = /static=static
static-map = /static=data/favicons
processes = 2 processes = 2
threads = 2 threads = 2
pidfile = /tmp/linkding.pid pidfile = /tmp/linkding.pid
@@ -15,6 +15,7 @@ die-on-term = true
if-env = LD_CONTEXT_PATH if-env = LD_CONTEXT_PATH
static-map = /%(_)static=static static-map = /%(_)static=static
static-map = /%(_)static=data/favicons
endif = endif =
if-env = LD_REQUEST_TIMEOUT if-env = LD_REQUEST_TIMEOUT

View File

@@ -1 +1 @@
1.16.0 1.17.1