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]
jobs:
run_tests:
name: Run Django Tests
unit_tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 14
- name: Install Python dependencies
run: pip install -r requirements.txt
node-version: 18
- name: Install Node dependencies
run: npm install
- name: Setup Python environment
run: pip install -r requirements.txt
- 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
## 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)
### What's Changed

View File

@@ -34,7 +34,7 @@ RUN mkdir /opt/venv && \
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
# copy prod dependencies
COPY --from=prod-deps /opt/venv /opt/venv
@@ -51,4 +51,8 @@ ENV PATH /opt/venv/bin:$PATH
RUN ["chmod", "g+w", "."]
# Run bootstrap logic
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"]

View File

@@ -1,4 +1,3 @@
from django.urls import reverse
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.response import Response
@@ -7,8 +6,8 @@ from rest_framework.routers import DefaultRouter
from bookmarks import queries
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
from bookmarks.services.website_loader import load_website_metadata
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader
from bookmarks.services.website_loader import WebsiteMetadata
class BookmarkViewSet(viewsets.GenericViewSet,
@@ -68,15 +67,13 @@ class BookmarkViewSet(viewsets.GenericViewSet,
def check(self, request):
url = request.GET.get('url')
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:
existing_bookmark_data = {
'id': bookmark.id,
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
}
metadata = load_website_metadata(url)
# Either return metadata from existing bookmark, or scrape from URL
if bookmark:
metadata = WebsiteMetadata(url, bookmark.website_title, bookmark.website_description)
else:
metadata = website_loader.load_website_metadata(url)
return Response({
'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_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
favicon_file = models.CharField(max_length=512, blank=True)
unread = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
shared = models.BooleanField(default=False)
@@ -161,12 +162,13 @@ class UserProfile(models.Model):
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
enable_sharing = models.BooleanField(default=False, null=False)
enable_favicons = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing']
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing', 'enable_favicons']
@receiver(post_save, sender=get_user_model())

View File

@@ -30,6 +30,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
bookmark.save()
# Create snapshot on web archive
tasks.create_web_archive_snapshot(current_user, bookmark, False)
# Load favicon
tasks.load_favicon(current_user, bookmark)
return bookmark
@@ -43,11 +45,15 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
# Update dates
bookmark.date_modified = timezone.now()
bookmark.save()
# Update favicon
tasks.load_favicon(current_user, bookmark)
if has_url_changed:
# Update web archive snapshot, if URL changed
tasks.create_web_archive_snapshot(current_user, bookmark, True)
# Only update website metadata if URL changed
_update_website_metadata(bookmark)
bookmark.save()
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
tasks.schedule_bookmarks_without_snapshots(user)
# Load favicons for newly imported bookmarks
tasks.schedule_bookmarks_without_favicons(user)
end = timezone.now()
logger.debug(f'Import duration: {end - import_start}')

View File

@@ -2,6 +2,7 @@ import logging
import waybackpy
from background_task import background
from background_task.models import Task
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
@@ -9,6 +10,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord
import bookmarks.services.wayback
from bookmarks.models import Bookmark, UserProfile
from bookmarks.services import favicon_loader
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
logger = logging.getLogger(__name__)
@@ -35,7 +37,7 @@ def _load_newest_snapshot(bookmark: Bookmark):
if existing_snapshot:
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}')
except NoCDXRecordFound:
@@ -49,7 +51,7 @@ def _create_snapshot(bookmark: Bookmark):
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT, max_tries=1)
archive.save()
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}')
@@ -72,7 +74,8 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
logger.error(
f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}')
except WaybackError as error:
logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}', exc_info=error)
logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}',
exc_info=error)
# Load the newest snapshot as fallback
_load_newest_snapshot(bookmark)
@@ -105,3 +108,67 @@ def _schedule_bookmarks_without_snapshots_task(user_id: int):
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
# new ones when processing bookmarks in bulk
_load_web_archive_snapshot_task(bookmark.id)
def is_favicon_feature_active(user: User) -> bool:
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
return background_tasks_enabled and user.profile.enable_favicons
def load_favicon(user: User, bookmark: Bookmark):
if is_favicon_feature_active(user):
_load_favicon_task(bookmark.id)
@background()
def _load_favicon_task(bookmark_id: int):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
logger.info(f'Load favicon for bookmark. url={bookmark.url}')
new_favicon = favicon_loader.load_favicon(bookmark.url)
if new_favicon != bookmark.favicon_file:
bookmark.favicon_file = new_favicon
bookmark.save(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
from dataclasses import dataclass
from functools import lru_cache
import requests
from bs4 import BeautifulSoup
from charset_normalizer import from_bytes
from django.utils import timezone
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):
title = None
description = None
try:
start = timezone.now()
page_text = load_page(url)
end = timezone.now()
logger.debug(f'Load duration: {end - start}')
start = timezone.now()
soup = BeautifulSoup(page_text, 'html.parser')
title = soup.title.string.strip() if soup.title is not None else None
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:
return WebsiteMetadata(url=url, title=title, description=description)
@@ -44,15 +57,19 @@ def load_page(url: str):
headers = fake_request_headers()
size = 0
content = None
iteration = 0
# 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:
for chunk in r.iter_content(chunk_size=CHUNK_SIZE):
size += len(chunk)
iteration = iteration + 1
if content is None:
content = chunk
else:
content = content + chunk
logger.debug(f'Loaded chunk (iteration={iteration}, total={size / 1024})')
# Stop reading if we have parsed end of head tag
if '</head>'.encode('utf-8') in content:
logger.debug(f'Found closing head tag after {size} bytes')
@@ -61,6 +78,8 @@ def load_page(url: str):
if size > MAX_CONTENT_LIMIT:
logger.debug(f'Cancel reading document after {size} bytes')
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
# 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;
}
.title img {
width: 16px;
height: 16px;
vertical-align: text-top;
}
.description {
color: $gray-color-dark;

View File

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

View File

@@ -15,8 +15,8 @@
</div>
{% endif %}
<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
by saving this form.
This URL is already bookmarked.
The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
</div>
</div>
<div class="form-group">
@@ -128,6 +128,9 @@
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.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 websiteDescriptionInput = document.getElementById('{{ form.website_description.id_for_label }}');
const editedBookmarkId = {{ bookmark_id }};
@@ -145,6 +148,14 @@
}
}
function updateInput(input, value) {
input.value = value;
}
function updateCheckbox(input, value) {
input.checked = value;
}
function checkUrl() {
toggleLoadingIcon(titleInput, true);
toggleLoadingIcon(descriptionInput, true);
@@ -162,13 +173,17 @@
toggleLoadingIcon(titleInput, 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 editExistingBookmarkLink = bookmarkExistsHint.querySelector('a');
if (data.bookmark && data.bookmark.id !== editedBookmarkId) {
if (existingBookmark && !editedBookmarkId) {
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 {
bookmarkExistsHint.style['display'] = 'none';
}

View File

@@ -36,6 +36,30 @@
Whether to open bookmarks a new page or in the same page.
</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">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
integration</label>
@@ -61,7 +85,14 @@
</div>
</div>
<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>
</form>
</section>

View File

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

View File

@@ -1,4 +1,6 @@
import urllib.parse
from collections import OrderedDict
from unittest.mock import patch
from django.contrib.auth.models import User
from django.urls import reverse
@@ -6,6 +8,8 @@ from rest_framework import status
from rest_framework.authtoken.models import Token
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
@@ -353,6 +357,66 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
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):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
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])
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>
''', 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:
rf = RequestFactory()
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>
</span>
''', 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 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, \
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.services import tasks
User = get_user_model()
@@ -19,6 +20,27 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
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):
original_bookmark = self.setup_bookmark(url='https://example.com', unread=False, shared=False)
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)
def test_create_should_load_favicon(self):
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
bookmark_data = Bookmark(url='https://example.com')
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark()
@@ -63,11 +92,21 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
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:
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.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user)
bookmark.refresh_from_db()
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):
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()
def test_update_should_update_favicon(self):
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_archive_bookmark(self):
bookmark = Bookmark(
url='https://example.com',

View File

@@ -1,6 +1,7 @@
import datetime
from dataclasses import dataclass
from unittest.mock import patch
from typing import Any
from unittest import mock
import waybackpy
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 waybackpy.exceptions import WaybackError
import bookmarks.services.favicon_loader
import bookmarks.services.wayback
from bookmarks.models import UserProfile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class MockWaybackMachineSaveAPI:
def __init__(self, archive_url: str = 'https://example.com/created_snapshot', fail_on_save: bool = False):
self.archive_url = archive_url
self.fail_on_save = fail_on_save
def create_wayback_machine_save_api_mock(archive_url: str = 'https://example.com/created_snapshot',
fail_on_save: bool = False):
mock_api = mock.Mock(archive_url=archive_url)
def save(self):
if self.fail_on_save:
raise WaybackError
return self
if fail_on_save:
mock_api.save.side_effect = WaybackError
return mock_api
@dataclass
@@ -31,21 +32,18 @@ class MockCdxSnapshot:
datetime_timestamp: datetime.datetime
class MockWaybackMachineCDXServerAPI:
def __init__(self,
archive_url: str = 'https://example.com/newest_snapshot',
has_no_snapshot=False,
fail_loading_snapshot=False):
self.archive_url = archive_url
self.has_no_snapshot = has_no_snapshot
self.fail_loading_snapshot = fail_loading_snapshot
def create_cdx_server_api_mock(archive_url: str | None = 'https://example.com/newest_snapshot',
fail_loading_snapshot=False):
mock_api = mock.Mock()
def newest(self):
if self.has_no_snapshot:
return None
if self.fail_loading_snapshot:
raise WaybackError
return MockCdxSnapshot(self.archive_url, datetime.datetime.now())
if fail_loading_snapshot:
mock_api.newest.side_effect = WaybackError
elif archive_url:
mock_api.newest.return_value = MockCdxSnapshot(archive_url, datetime.datetime.now())
else:
mock_api.newest.return_value = None
return mock_api
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
@@ -53,49 +51,55 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
user = self.get_or_create_test_user()
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
user.profile.enable_favicons = True
user.profile.save()
@disable_logging
def run_pending_task(self, task_function):
def run_pending_task(self, task_function: Any):
func = getattr(task_function, 'task_function', None)
task = Task.objects.all()[0]
self.assertEqual(task_function.name, task.task_name)
args, kwargs = task.params()
func(*args, **kwargs)
task.delete()
@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)
tasks = Task.objects.all()
for task in tasks:
self.assertEqual(task_function.name, task.task_name)
args, kwargs = task.params()
func(*args, **kwargs)
task.delete()
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
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)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
mock_save_api.save.assert_called_once()
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot')
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
mock_save_api = create_wayback_machine_save_api_mock()
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
tasks._create_web_archive_snapshot_task(123, False)
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):
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',
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
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)
@@ -103,9 +107,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
mock_save_api = create_wayback_machine_save_api_mock(archive_url='https://other.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI('https://other.com')):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
@@ -114,24 +118,27 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
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',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_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()
mock_cdx_api.newest.assert_called_once()
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
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',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_api):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
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):
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',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_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.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):
bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()):
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()
mock_cdx_api.newest.assert_called_once()
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
mock_cdx_api = create_cdx_server_api_mock()
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=mock_cdx_api):
tasks._load_web_archive_snapshot_task(123)
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):
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',
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(bookmark.id)
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):
bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
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)
@@ -192,14 +226,37 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
bookmark = self.setup_bookmark()
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
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)
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)
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark()
@@ -262,3 +319,177 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(Task.objects.count(), 0)
def test_load_favicon_should_create_favicon_file(self):
bookmark = self.setup_bookmark()
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
mock_load_favicon.return_value = 'https_example_com.png'
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
self.run_pending_task(tasks._load_favicon_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.favicon_file, 'https_example_com.png')
def test_load_favicon_should_update_favicon_file(self):
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
mock_load_favicon.return_value = 'https_example_updated_com.png'
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
self.run_pending_task(tasks._load_favicon_task)
mock_load_favicon.assert_called()
bookmark.refresh_from_db()
self.assertEqual(bookmark.favicon_file, 'https_example_updated_com.png')
def test_load_favicon_should_handle_missing_bookmark(self):
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
tasks._load_favicon_task(123)
self.run_pending_task(tasks._load_favicon_task)
mock_load_favicon.assert_not_called()
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)
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
from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch, Mock
import requests
from django.test import TestCase, override_settings
from django.urls import reverse
from requests import RequestException
from bookmarks.models import UserProfile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info
@@ -17,6 +18,20 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_profile_form_data(self, overrides=None):
if not overrides:
overrides = {}
form_data = {
'theme': UserProfile.THEME_AUTO,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_BLANK,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
'enable_sharing': False,
'enable_favicons': False,
}
return {**form_data, **overrides}
def test_should_render_successfully(self):
response = self.client.get(reverse('bookmarks:settings.general'))
@@ -28,15 +43,18 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general'))
def test_should_save_profile(self):
def test_update_profile(self):
form_data = {
'update_profile': '',
'theme': UserProfile.THEME_DARK,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
'enable_sharing': True,
'enable_favicons': True,
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
self.user.profile.refresh_from_db()
@@ -46,6 +64,118 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing'])
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
self.assertInHTML('''
<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):
response = self.client.get(reverse('bookmarks:settings.general'))

View File

@@ -25,6 +25,10 @@ class MockStreamingResponse:
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):
return f'''
<!DOCTYPE html>

View File

@@ -31,4 +31,6 @@ urlpatterns = [
# Feeds
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
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 .settings 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
import requests
from django.conf import settings as django_settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import prefetch_related_objects
@@ -13,7 +14,7 @@ from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm, FeedToken
from bookmarks.queries import query_bookmarks
from bookmarks.services import exporter
from bookmarks.services import exporter, tasks
from bookmarks.services import importer
logger = logging.getLogger(__name__)
@@ -28,24 +29,48 @@ except Exception as exc:
@login_required
def general(request):
if request.method == 'POST':
form = UserProfileForm(request.POST, instance=request.user.profile)
if form.is_valid():
form.save()
else:
form = UserProfileForm(instance=request.user.profile)
profile_form = None
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
update_profile_success_message = None
refresh_favicons_success_message = None
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
version_info = get_version_info(get_ttl_hash())
if request.method == 'POST':
if 'update_profile' in request.POST:
profile_form = update_profile(request)
update_profile_success_message = 'Profile updated'
if 'refresh_favicons' in request.POST:
tasks.schedule_refresh_favicons(request.user)
refresh_favicons_success_message = 'Scheduled favicon update. This may take a while...'
if not profile_form:
profile_form = UserProfileForm(instance=request.user.profile)
return render(request, 'settings/general.html', {
'form': form,
'form': profile_form,
'enable_refresh_favicons': enable_refresh_favicons,
'update_profile_success_message': update_profile_success_message,
'refresh_favicons_success_message': refresh_favicons_success_message,
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
'version_info': version_info,
})
def update_profile(request):
user = request.user
profile = user.profile
favicons_were_enabled = profile.enable_favicons
form = UserProfileForm(request.POST, instance=profile)
if form.is_valid():
form.save()
if profile.enable_favicons and not favicons_were_enabled:
tasks.schedule_bookmarks_without_favicons(request.user)
return form
# Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1)
def get_version_info(ttl_hash=None):

View File

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

View File

@@ -149,3 +149,15 @@ Values: `Integer` | Default = None
The port of the database server.
Should use the default port if left empty, for example `5432` for PostgresSQL.
### `LD_FAVICON_PROVIDER`
Values: `String` | Default = `https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON`
The favicon provider used for downloading icons if they are enabled in the user profile settings.
The default provider is a Google service that automatically detects the correct favicon for a website, and provides icons in consistent image format (PNG) and in a consistent image size.
This setting allows to configure a custom provider in form of a URL.
When calling the provider with the URL of a website, it must return the image data for the favicon of that website.
The configured favicon provider URL must contain a `{url}` placeholder that will be replaced with the URL of the website for which to download the favicon.
See the default URL for an example.

16
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
asgiref==3.5.2
beautifulsoup4==4.11.1
certifi==2022.6.15
certifi==2022.12.7
charset-normalizer==2.1.1
click==8.1.3
confusable-homoglyphs==3.2.0
coverage==5.5
Django==4.1
Django==4.1.2
django-appconf==1.0.5
django-compressor==4.1
django-debug-toolbar==3.6.0
@@ -15,9 +15,12 @@ django-sass-processor==1.2.1
django-widget-tweaks==1.4.12
django4-background-tasks==1.2.7
djangorestframework==3.13.1
greenlet==2.0.1
idna==3.3
libsass==0.21.0
playwright==1.29.1
psycopg2-binary==2.9.5
pyee==9.0.4
python-dateutil==2.8.2
pytz==2022.2.1
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
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'bookmarks', 'styles'),
os.path.join(BASE_DIR, 'data', 'favicons'),
]
# REST framework
@@ -222,3 +223,9 @@ else:
DATABASES = {
'default': default_database
}
# Favicons
LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON'
LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER)
LD_FAVICON_FOLDER = os.path.join(BASE_DIR, 'data', 'favicons')
LD_ENABLE_REFRESH_FAVICONS = os.getenv('LD_ENABLE_REFRESH_FAVICONS', True) in (True, 'True', '1')

View File

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

View File

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

View File

@@ -1 +1 @@
1.16.0
1.17.1