Compare commits

...

6 Commits

Author SHA1 Message Date
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
35 changed files with 971 additions and 67 deletions

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## 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) ## v1.16.0 (12/01/2023)
### 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

@@ -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,6 +45,9 @@ 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)

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
@@ -10,6 +11,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.website_loader import DEFAULT_USER_AGENT from bookmarks.services.website_loader import DEFAULT_USER_AGENT
from bookmarks.services import favicon_loader
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -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()
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,5 +1,6 @@
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
@@ -23,6 +24,9 @@ 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

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

@@ -67,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()
@@ -109,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,6 @@
import datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
from unittest.mock import patch from unittest import mock
import waybackpy import waybackpy
from background_task.models import Task from background_task.models import Task
@@ -8,6 +8,7 @@ from django.contrib.auth.models import User
from django.test import TestCase, override_settings from 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
@@ -53,12 +54,14 @@ 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):
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()
@@ -69,6 +72,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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()
@@ -76,7 +80,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()): with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) 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()
@@ -84,8 +88,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI()) as mock_save_api: return_value=MockWaybackMachineSaveAPI()) as 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)
@@ -94,8 +98,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI()) as 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)
@@ -104,8 +108,8 @@ 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')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI('https://other.com')): 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()
@@ -115,10 +119,10 @@ 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()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()): 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)
@@ -128,10 +132,10 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)): 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)
@@ -141,10 +145,10 @@ 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()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): return_value=MockWaybackMachineSaveAPI(fail_on_save=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=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)
@@ -154,8 +158,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()): return_value=MockWaybackMachineCDXServerAPI()):
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)
@@ -163,8 +167,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api: return_value=MockWaybackMachineCDXServerAPI()) as 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)
@@ -173,8 +177,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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')
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api: return_value=MockWaybackMachineCDXServerAPI()) as 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)
@@ -183,8 +187,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)): return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
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)
@@ -193,8 +197,8 @@ 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()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)): return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
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)
@@ -262,3 +266,157 @@ 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()
@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.

View File

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

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

@@ -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.1 1.17.0