Compare commits

...

11 Commits

Author SHA1 Message Date
Sascha Ißbrücker
992dc69a36 Bump version 2022-08-14 13:41:53 +02:00
Sascha Ißbrücker
c9c6b097d0 Add support for authentication proxies (#321)
* add support for auth proxies

* Improve docs
2022-08-14 13:35:03 +02:00
Sascha Ißbrücker
1308370027 Add bookmark list keyboard navigation (#320)
* Add bookmark list keyboard navigation

* Fix focus outline for title link

* Combine bookmark list scripts
2022-08-14 11:28:36 +02:00
Mingshi Cai
5af4d41ee1 Add project linka to community section in README (#319)
Co-authored-by: masoncai <masoncai@tencent.com>
2022-08-13 15:52:32 +02:00
dependabot[bot]
70b3f824eb Bump django from 3.2.14 to 3.2.15 (#316)
Bumps [django](https://github.com/django/django) from 3.2.14 to 3.2.15.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.14...3.2.15)

---
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>
2022-08-13 11:24:48 +02:00
Sascha Ißbrücker
1b67081773 Skip updating website metadata on edit unless URL has changed (#318)
* Skip updating website metadata on edit unless URL has changed

* Prevent form fetching metadata when editing existing bookmark
2022-08-13 11:21:26 +02:00
Rodrigo Candido Gryzinski
ee7ac775d2 Order tags in bookmark tests (#310) 2022-08-11 09:04:57 +02:00
s2marine
8053468ca5 Add support for context path (#313)
* Add support for context path

add an optional environment variable: LD_CONTEXT_PATH

* Fix for pull request code review comments

Co-authored-by: s2marine <s2marine@gmail.com>
2022-08-07 12:41:11 +02:00
Aaron Bach
eadae32eb3 Add simple docs of the new shared API parameter (#312) 2022-08-05 09:34:03 +02:00
Sascha Ißbrücker
2f0dd0db0d Update README.md 2022-08-04 21:18:56 +02:00
Sascha Ißbrücker
da4ed5b7c1 Update CHANGELOG.md 2022-08-04 21:05:30 +02:00
30 changed files with 428 additions and 143 deletions

View File

@@ -4,6 +4,9 @@ LD_CONTAINER_NAME=linkding
LD_HOST_PORT=9090
# Directory on the host system that should be mounted as data dir into the Docker container
LD_HOST_DATA_DIR=./data
# Can be used to run linkding under a context path, for example: linkding/
# Must end with a slash `/`
LD_CONTEXT_PATH=
# Option to disable background tasks
LD_DISABLE_BACKGROUND_TASKS=False

View File

@@ -1,5 +1,17 @@
# Changelog
## v1.13.0 (04/08/2022)
### What's Changed
* Add bookmark sharing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/311
* Display selected tags in tag cloud by @sissbruecker and @jhauris in https://github.com/sissbruecker/linkding/pull/307
* Update unread flag when saving duplicate URL by @sissbruecker in https://github.com/sissbruecker/linkding/pull/306
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.12.0...v1.13.0
---
## v1.12.0 (23/07/2022)
### What's Changed

View File

@@ -29,18 +29,19 @@ The name comes from:
- ...so basically something for managing your links
**Feature Overview:**
- Tags for organizing bookmarks
- Search by text or tags
- Organize bookmarks with tags
- Read it later functionality
- Share bookmarks with other users
- Bulk editing
- Bookmark archive
- Dark mode
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Automatically provides titles and descriptions of bookmarked websites
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Import and export bookmarks in Netscape HTML format
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), and a bookmarklet that should work in most browsers
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
- Light and dark themes
- REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access
- Easy to set up using Docker, uses SQLite as database
- Easy setup using Docker, uses SQLite as database
**Demo:** https://demo.linkding.link/ (configured with open registration)
@@ -126,6 +127,7 @@ The extension is open-source as well, and can be found [here](https://github.com
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)

6
bookmarks/middlewares.py Normal file
View File

@@ -0,0 +1,6 @@
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
header = settings.LD_AUTH_PROXY_USERNAME_HEADER

View File

@@ -101,6 +101,11 @@ class BookmarkForm(forms.ModelForm):
required=False)
description = forms.CharField(required=False,
widget=forms.Textarea())
# Include website title and description as hidden field as they only provide info when editing bookmarks
website_title = forms.CharField(max_length=512,
required=False, widget=forms.HiddenInput())
website_description = forms.CharField(required=False,
widget=forms.HiddenInput())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
@@ -108,7 +113,17 @@ class BookmarkForm(forms.ModelForm):
class Meta:
model = Bookmark
fields = ['url', 'tag_string', 'title', 'description', 'unread', 'shared', 'auto_close']
fields = [
'url',
'tag_string',
'title',
'description',
'website_title',
'website_description',
'unread',
'shared',
'auto_close',
]
class BookmarkFilters:

View File

@@ -5,7 +5,7 @@ from django.utils import timezone
from bookmarks.models import Bookmark, parse_tag_string
from bookmarks.services.tags import get_or_create_tags
from bookmarks.services.website_loader import load_website_metadata
from bookmarks.services import website_loader
from bookmarks.services import tasks
@@ -38,16 +38,16 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
# Detect URL change
original_bookmark = Bookmark.objects.get(id=bookmark.id)
has_url_changed = original_bookmark.url != bookmark.url
# Update website info
_update_website_metadata(bookmark)
# Update tag list
_update_bookmark_tags(bookmark, tag_string, current_user)
# Update dates
bookmark.date_modified = timezone.now()
bookmark.save()
# Update web archive snapshot, if URL changed
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)
return bookmark
@@ -121,7 +121,7 @@ def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
def _update_website_metadata(bookmark: Bookmark):
metadata = load_website_metadata(bookmark.url)
metadata = website_loader.load_website_metadata(bookmark.url)
bookmark.website_title = metadata.title
bookmark.website_description = metadata.description

View File

@@ -0,0 +1,127 @@
(function () {
function setupBulkEdit() {
const bulkEditToggle = document.getElementById('bulk-edit-mode')
const bulkEditBar = document.querySelector('.bulk-edit-bar')
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
const allToggle = document.querySelector('.bulk-edit-all-toggle input')
function isAllSelected() {
let result = true
singleToggles.forEach(function (toggle) {
result = result && toggle.checked
})
return result
}
function selectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = true
})
}
function deselectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = false
})
}
// Toggle all
allToggle.addEventListener('change', function (e) {
if (e.target.checked) {
selectAll()
} else {
deselectAll()
}
})
// Toggle single
singleToggles.forEach(function (toggle) {
toggle.addEventListener('change', function () {
allToggle.checked = isAllSelected()
})
})
// Allow overflow when bulk edit mode is active to be able to display tag auto complete menu
let bulkEditToggleTimeout
if (bulkEditToggle.checked) {
bulkEditBar.style.overflow = 'visible';
}
bulkEditToggle.addEventListener('change', function (e) {
if (bulkEditToggleTimeout) {
clearTimeout(bulkEditToggleTimeout);
bulkEditToggleTimeout = null;
}
if (e.target.checked) {
bulkEditToggleTimeout = setTimeout(function () {
bulkEditBar.style.overflow = 'visible';
}, 500);
} else {
bulkEditBar.style.overflow = 'hidden';
}
});
}
function setupBulkEditTagAutoComplete() {
const wrapper = document.createElement('div');
const tagInput = document.getElementById('bulk-edit-tags-input');
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
const apiClient = new linkding.ApiClient(apiBaseUrl)
new linkding.TagAutoComplete({
target: wrapper,
props: {
id: 'bulk-edit-tags-input',
name: tagInput.name,
value: tagInput.value,
apiClient: apiClient,
variant: 'small'
}
});
tagInput.parentElement.replaceChild(wrapper, tagInput);
}
function setupListNavigation() {
// Add logic for navigating bookmarks with arrow keys
document.addEventListener('keydown', event => {
// Skip if event occurred within an input element
// or does not use arrow keys
const targetNodeName = event.target.nodeName;
const isInputTarget = targetNodeName === 'INPUT'
|| targetNodeName === 'SELECT'
|| targetNodeName === 'TEXTAREA';
const isArrowUp = event.key === 'ArrowUp';
const isArrowDown = event.key === 'ArrowDown';
if (isInputTarget || !(isArrowUp || isArrowDown)) {
return;
}
event.preventDefault();
// Detect current bookmark list item
const path = event.composedPath();
const currentItem = path.find(item => item.hasAttribute && item.hasAttribute('data-is-bookmark-item'));
// Find next item
let nextItem;
if (currentItem) {
nextItem = isArrowUp
? currentItem.previousElementSibling
: currentItem.nextElementSibling;
} else {
// Select first item
nextItem = document.querySelector('li[data-is-bookmark-item]');
}
// Focus first link
if (nextItem) {
nextItem.querySelector('a').focus();
}
});
}
setupBulkEdit();
setupBulkEditTagAutoComplete();
setupListNavigation();
})()

View File

@@ -1,86 +0,0 @@
(function () {
const bulkEditToggle = document.getElementById('bulk-edit-mode')
const bulkEditBar = document.querySelector('.bulk-edit-bar')
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
const allToggle = document.querySelector('.bulk-edit-all-toggle input')
function isAllSelected() {
let result = true
singleToggles.forEach(function (toggle) {
result = result && toggle.checked
})
return result
}
function selectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = true
})
}
function deselectAll() {
singleToggles.forEach(function (toggle) {
toggle.checked = false
})
}
// Toggle all
allToggle.addEventListener('change', function (e) {
if (e.target.checked) {
selectAll()
} else {
deselectAll()
}
})
// Toggle single
singleToggles.forEach(function (toggle) {
toggle.addEventListener('change', function () {
allToggle.checked = isAllSelected()
})
})
// Allow overflow when bulk edit mode is active to be able to display tag auto complete menu
let bulkEditToggleTimeout
if (bulkEditToggle.checked) {
bulkEditBar.style.overflow = 'visible';
}
bulkEditToggle.addEventListener('change', function (e) {
if (bulkEditToggleTimeout) {
clearTimeout(bulkEditToggleTimeout);
bulkEditToggleTimeout = null;
}
if (e.target.checked) {
bulkEditToggleTimeout = setTimeout(function () {
bulkEditBar.style.overflow = 'visible';
}, 500);
} else {
bulkEditBar.style.overflow = 'hidden';
}
});
// Init tag auto-complete
function initTagAutoComplete() {
const wrapper = document.createElement('div');
const tagInput = document.getElementById('bulk-edit-tags-input');
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
const apiClient = new linkding.ApiClient(apiBaseUrl)
new linkding.TagAutoComplete({
target: wrapper,
props: {
id: 'bulk-edit-tags-input',
name: tagInput.name,
value: tagInput.value,
apiClient: apiClient,
variant: 'small'
}
});
tagInput.parentElement.replaceChild(wrapper, tagInput);
}
initTagAutoComplete();
})()

View File

@@ -49,6 +49,15 @@ ul.bookmark-list {
margin: 0;
padding: 0;
.title a {
display: inline-block;
vertical-align: top;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.description {
color: $gray-color-dark;

View File

@@ -42,5 +42,5 @@
<script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bulk_edit.js" %}"></script>
<script src="{% static "bookmark_list.js" %}"></script>
{% endblock %}

View File

@@ -8,8 +8,11 @@
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<i class="form-icon"></i>
</label>
<div class="title truncate">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener" class="{% if bookmark.unread %}text-italic{% endif %}">{{ bookmark.resolved_title }}</a>
<div class="title">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="{% if bookmark.unread %}text-italic{% endif %}">
{{ bookmark.resolved_title }}
</a>
</div>
<div class="description truncate">
{% if bookmark.tag_names %}

View File

@@ -3,6 +3,8 @@
<div class="bookmarks-form">
{% csrf_token %}
{{ form.website_title }}
{{ form.website_description }}
{{ form.auto_close|attr:"type:hidden" }}
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
@@ -126,6 +128,8 @@
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 websiteTitleInput = document.getElementById('{{ form.website_title.id_for_label }}');
const websiteDescriptionInput = document.getElementById('{{ form.website_description.id_for_label }}');
const editedBookmarkId = {{ bookmark_id }};
function toggleLoadingIcon(input, show) {
@@ -182,8 +186,19 @@
});
}
if (urlInput.value) checkUrl();
// Fetch initial website data if we have a URL, and we are not editing an existing bookmark
// For existing bookmarks we get the website metadata through hidden inputs
if (urlInput.value && !editedBookmarkId) {
checkUrl();
}
urlInput.addEventListener('input', checkUrl);
// Set initial website title and description for edited bookmarks
if (editedBookmarkId) {
updatePlaceholder(titleInput, websiteTitleInput.value);
updatePlaceholder(descriptionInput, websiteDescriptionInput.value);
}
setupEditAutoValueButton(titleInput);
setupEditAutoValueButton(descriptionInput);
})();

View File

@@ -42,5 +42,5 @@
<script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
<script src="{% static "bulk_edit.js" %}"></script>
<script src="{% static "bookmark_list.js" %}"></script>
{% endblock %}

View File

@@ -44,7 +44,7 @@
{% endif %}
<div class="navbar container grid-lg">
<section class="navbar-section">
<a href="/" class="navbar-brand text-bold">
<a href="{% url 'bookmarks:index' %}" class="navbar-brand text-bold">
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>linkding</h1>
</a>

View File

@@ -0,0 +1,46 @@
from unittest.mock import patch, PropertyMock
from django.test import TestCase, modify_settings
from django.urls import reverse
from bookmarks.models import User
from bookmarks.middlewares import CustomRemoteUserMiddleware
class AuthProxySupportTest(TestCase):
# Reproducing configuration from the settings logic here
# ideally this test would just override the respective options
@modify_settings(
MIDDLEWARE={'append': 'bookmarks.middlewares.CustomRemoteUserMiddleware'},
AUTHENTICATION_BACKENDS={'prepend': 'django.contrib.auth.backends.RemoteUserBackend'}
)
def test_auth_proxy_authentication(self):
user = User.objects.create_user('auth_proxy_user', 'user@example.com', 'password123')
headers = {'REMOTE_USER': user.username}
response = self.client.get(reverse('bookmarks:index'), **headers)
self.assertEqual(response.status_code, 200)
# Reproducing configuration from the settings logic here
# ideally this test would just override the respective options
@modify_settings(
MIDDLEWARE={'append': 'bookmarks.middlewares.CustomRemoteUserMiddleware'},
AUTHENTICATION_BACKENDS={'prepend': 'django.contrib.auth.backends.RemoteUserBackend'}
)
def test_auth_proxy_with_custom_header(self):
with patch.object(CustomRemoteUserMiddleware, 'header', new_callable=PropertyMock) as mock:
mock.return_value = 'Custom-User'
user = User.objects.create_user('auth_proxy_user', 'user@example.com', 'password123')
headers = {'Custom-User': user.username}
response = self.client.get(reverse('bookmarks:index'), **headers)
self.assertEqual(response.status_code, 200)
def test_auth_proxy_is_disabled_by_default(self):
user = User.objects.create_user('auth_proxy_user', 'user@example.com', 'password123')
headers = {'REMOTE_USER': user.username}
response = self.client.get(reverse('bookmarks:index'), **headers, follow=True)
self.assertRedirects(response, '/login/?next=%2Fbookmarks')

View File

@@ -40,8 +40,9 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'editedtag1')
self.assertEqual(bookmark.tags.all()[1].name, 'editedtag2')
tags = bookmark.tags.order_by('name').all()
self.assertEqual(tags[0].name, 'editedtag1')
self.assertEqual(tags[1].name, 'editedtag2')
def test_should_edit_unread_state(self):
bookmark = self.setup_bookmark()
@@ -72,32 +73,43 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_prefill_bookmark_form_fields(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description')
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description',
website_title='website title', website_description='website description')
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode()
self.assertInHTML('<input type="text" name="url" '
'value="{0}" placeholder=" " '
'autofocus class="form-input" required '
'id="id_url">'.format(bookmark.url),
html)
self.assertInHTML(f'''
<input type="text" name="url" value="{bookmark.url}" placeholder=" "
autofocus class="form-input" required id="id_url">
''', html)
tag_string = build_tag_string(bookmark.tag_names, ' ')
self.assertInHTML('<input type="text" name="tag_string" '
'value="{0}" autocomplete="off" '
'class="form-input" '
'id="id_tag_string">'.format(tag_string),
html)
self.assertInHTML(f'''
<input type="text" name="tag_string" value="{tag_string}"
autocomplete="off" class="form-input" id="id_tag_string">
''', html)
self.assertInHTML('<input type="text" name="title" maxlength="512" '
'autocomplete="off" class="form-input" '
'value="{0}" id="id_title">'.format(bookmark.title),
html)
self.assertInHTML(f'''
<input type="text" name="title" value="{bookmark.title}" maxlength="512" autocomplete="off"
class="form-input" id="id_title">
''', html)
self.assertInHTML('<textarea name="description" cols="40" rows="2" class="form-input" id="id_description">{0}'
'</textarea>'.format(bookmark.description),
html)
self.assertInHTML(f'''
<textarea name="description" cols="40" rows="2" class="form-input" id="id_description">
{bookmark.description}
</textarea>
''', html)
self.assertInHTML(f'''
<input type="hidden" name="website_title" id="id_website_title"
value="{bookmark.website_title}">
''', html)
self.assertInHTML(f'''
<input type="hidden" name="website_description" id="id_website_description"
value="{bookmark.website_description}">
''', html)
def test_should_redirect_to_return_url(self):
bookmark = self.setup_bookmark()
@@ -172,4 +184,3 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
<span>Share</span>
</label>
''', html, count=1)

View File

@@ -40,8 +40,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'tag1')
self.assertEqual(bookmark.tags.all()[1].name, 'tag2')
tags = bookmark.tags.order_by('name').all()
self.assertEqual(tags[0].name, 'tag1')
self.assertEqual(tags[1].name, 'tag2')
def test_should_create_new_unread_bookmark(self):
form_data = self.create_form_data({'unread': True})

View File

@@ -7,6 +7,7 @@ from django.utils import timezone
from bookmarks.models import Bookmark, Tag
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.tests.helpers import BookmarkFactoryMixin
from bookmarks.services import tasks
@@ -60,6 +61,22 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_create_web_archive_snapshot.assert_not_called()
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:
bookmark = self.setup_bookmark()
bookmark.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_load_website_metadata.assert_called_once()
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:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_load_website_metadata.assert_not_called()
def test_archive_bookmark(self):
bookmark = Bookmark(
url='https://example.com',

View File

@@ -0,0 +1,53 @@
import importlib
from django.test import TestCase, override_settings
from django.urls import reverse
class MockUrlConf:
def __init__(self, module):
self.urlpatterns = module.urlpatterns
class ContextPathTestCase(TestCase):
def setUp(self):
self.siteroot_urls = importlib.import_module('siteroot.urls')
@override_settings(LD_CONTEXT_PATH=None)
def tearDown(self):
importlib.reload(self.siteroot_urls)
@override_settings(LD_CONTEXT_PATH='linkding/')
def test_route_with_context_path(self):
module = importlib.reload(self.siteroot_urls)
# pass mock config instead of actual module to prevent caching the
# url config in django.urls.reverse
urlconf = MockUrlConf(module)
test_cases = [
('bookmarks:index', '/linkding/bookmarks'),
('bookmarks:bookmark-list', '/linkding/api/bookmarks/'),
('login', '/linkding/login/'),
('admin:bookmarks_bookmark_changelist', '/linkding/admin/bookmarks/bookmark/'),
]
for url_name, expected_url in test_cases:
url = reverse(url_name, urlconf=urlconf)
self.assertEqual(expected_url, url)
@override_settings(LD_CONTEXT_PATH='')
def test_route_without_context_path(self):
module = importlib.reload(self.siteroot_urls)
# pass mock config instead of actual module to prevent caching the
# url config in django.urls.reverse
urlconf = MockUrlConf(module)
test_cases = [
('bookmarks:index', '/bookmarks'),
('bookmarks:bookmark-list', '/api/bookmarks/'),
('login', '/login/'),
('admin:bookmarks_bookmark_changelist', '/admin/bookmarks/bookmark/'),
]
for url_name, expected_url in test_cases:
url = reverse(url_name, urlconf=urlconf)
self.assertEqual(expected_url, url)

View File

@@ -73,7 +73,7 @@ def get_ttl_hash(seconds=3600):
@login_required
def integrations(request):
application_url = request.build_absolute_uri("/bookmarks/new")
application_url = request.build_absolute_uri(reverse('bookmarks:new'))
api_token = Token.objects.get_or_create(user=request.user)[0]
feed_token = FeedToken.objects.get_or_create(user=request.user)[0]
all_feed_url = request.build_absolute_uri(reverse('bookmarks:feeds.all', args=[feed_token.key]))

View File

@@ -49,6 +49,7 @@ Example response:
"website_description": "Website description",
"is_archived": false,
"unread": false,
"shared": false,
"tag_names": [
"tag1",
"tag2"
@@ -97,6 +98,7 @@ Example payload:
"description": "Example description",
"is_archived": false,
"unread": false,
"shared": false,
"tag_names": [
"tag1",
"tag2"

View File

@@ -50,4 +50,30 @@ Configures the request timeout in the uwsgi application server. This can be usef
Values: Valid port number | Default = `9090`
Allows to set a custom port for the UWSGI server running in the container. While Docker containers have their own IP address namespace and port collisions are impossible to achieve, there are other container solutions that share one. Podman, for example, runs all containers in a pod under one namespace, which results in every port only being allowed to be assigned once. This option allows to set a custom port in order to avoid collisions with other containers.
Allows to set a custom port for the UWSGI server running in the container. While Docker containers have their own IP address namespace and port collisions are impossible to achieve, there are other container solutions that share one. Podman, for example, runs all containers in a pod under one namespace, which results in every port only being allowed to be assigned once. This option allows to set a custom port in order to avoid collisions with other containers.
### `LD_CONTEXT_PATH`
Values: `String` | Default = None
Allows configuring the context path of the website. Useful for setting up Nginx reverse proxy.
The context path must end with a slash. For example: `linkding/`
### `LD_ENABLE_AUTH_PROXY`
Values: `True`, `False` | Default = `False`
Enables support for authentication proxies such as Authelia.
This effectively disables credentials-based authentication and instead authenticates users if a specific request header contains a known username.
You must make sure that your proxy (nginx, Traefik, Caddy, ...) forwards this header from your auth proxy to linkding. Check the documentation of your auth proxy and your reverse proxy on how to correctly set this up.
Note that this does not automatically create new users, you still need to create users as described in the README, and users need to have the same username as in the auth proxy.
Enabling this setting also requires configuring the following options:
- `LD_AUTH_PROXY_USERNAME_HEADER` - The name of the request header that the auth proxy passes to the proxied application (linkding in this case), so that the application can identify the user.
Check the documentation of your auth proxy to get this information.
Note that the request headers are rewritten in linkding: all HTTP headers are prefixed with `HTTP_`, all letters are in uppercase, and dashes are replaced with underscores.
For example, for Authelia, which passes the `Remote-User` HTTP header, the `LD_AUTH_PROXY_USERNAME_HEADER` needs to be configured as `HTTP_REMOTE_USER`.
- `LD_AUTH_PROXY_LOGOUT_URL` - The URL that linkding should redirect to after a logout.
By default, the logout redirects to the login URL, which means the user will be automatically authenticated again.
Instead, you might want to configure the logout URL of the auth proxy here.

View File

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

View File

@@ -3,7 +3,7 @@ beautifulsoup4==4.7.1
certifi==2019.6.16
charset-normalizer==2.0.4
confusable-homoglyphs==3.2.0
Django==3.2.14
Django==3.2.15
django-background-tasks==1.2.5
django-compat==1.0.15
django-generate-secret-key==1.0.2

View File

@@ -4,7 +4,7 @@ certifi==2019.6.16
charset-normalizer==2.0.4
confusable-homoglyphs==3.2.0
coverage==5.5
Django==3.2.14
Django==3.2.15
django-appconf==1.0.4
django-background-tasks==1.2.5
django-compat==1.0.15

View File

@@ -107,9 +107,12 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
LOGIN_URL = '/login'
LOGIN_REDIRECT_URL = '/bookmarks'
LOGOUT_REDIRECT_URL = '/login'
# Website context path.
LD_CONTEXT_PATH = os.getenv('LD_CONTEXT_PATH', '')
LOGIN_URL = '/' + LD_CONTEXT_PATH + 'login'
LOGIN_REDIRECT_URL = '/' + LD_CONTEXT_PATH + 'bookmarks'
LOGOUT_REDIRECT_URL = '/' + LD_CONTEXT_PATH + 'login'
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
@@ -127,7 +130,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = '/static/'
STATIC_URL = '/' + LD_CONTEXT_PATH + 'static/'
# Collect static files in static folder
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
@@ -179,3 +182,20 @@ MAX_ATTEMPTS = 5
# specced systems like Raspberries. Should be OK as tasks are not time critical.
BACKGROUND_TASK_RUN_ASYNC = True
BACKGROUND_TASK_ASYNC_THREADS = 2
# Enable authentication proxy support if configured
LD_ENABLE_AUTH_PROXY = os.getenv('LD_ENABLE_AUTH_PROXY', False) in (True, 'True', '1')
LD_AUTH_PROXY_USERNAME_HEADER = os.getenv('LD_AUTH_PROXY_USERNAME_HEADER', 'REMOTE_USER')
LD_AUTH_PROXY_LOGOUT_URL = os.getenv('LD_AUTH_PROXY_LOGOUT_URL', None)
if LD_ENABLE_AUTH_PROXY:
# Add middleware that automatically authenticates requests that have a known username
# in the LD_AUTH_PROXY_USERNAME_HEADER request header
MIDDLEWARE.append('bookmarks.middlewares.CustomRemoteUserMiddleware')
# Configure auth backend that does not require a password credential
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.RemoteUserBackend',
]
# Configure logout URL
if LD_AUTH_PROXY_LOGOUT_URL:
LOGOUT_REDIRECT_URL = LD_AUTH_PROXY_LOGOUT_URL

View File

@@ -44,12 +44,7 @@ LOGGING = {
'level': 'ERROR', # Set to DEBUG to log all SQL calls
'handlers': ['console'],
},
'bookmarks.services.tasks': { # Log task output
'level': 'DEBUG',
'handlers': ['console'],
'propagate': False,
},
'bookmarks.services.importer': { # Log importer debug output
'bookmarks': { # Log importer debug output
'level': 'DEBUG',
'handlers': ['console'],
'propagate': False,

View File

@@ -13,6 +13,7 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.contrib.auth import views as auth_views
from django.urls import path, include
@@ -30,6 +31,9 @@ urlpatterns = [
path('', include('bookmarks.urls')),
]
if settings.LD_CONTEXT_PATH:
urlpatterns = [path(settings.LD_CONTEXT_PATH, include(urlpatterns))]
if DEBUG:
import debug_toolbar

View File

@@ -12,6 +12,10 @@ uid = www-data
gid = www-data
buffer-size = 8192
if-env = LD_CONTEXT_PATH
static-map = /%(_)static=static
endif =
if-env = LD_REQUEST_TIMEOUT
http-timeout = %(_)
socket-timeout = %(_)

View File

@@ -1 +1 @@
1.13.0
1.14.0