mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-15 22:49:23 +02:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
992dc69a36 | ||
![]() |
c9c6b097d0 | ||
![]() |
1308370027 | ||
![]() |
5af4d41ee1 | ||
![]() |
70b3f824eb | ||
![]() |
1b67081773 | ||
![]() |
ee7ac775d2 | ||
![]() |
8053468ca5 | ||
![]() |
eadae32eb3 | ||
![]() |
2f0dd0db0d | ||
![]() |
da4ed5b7c1 |
@@ -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
|
||||
|
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||
|
14
README.md
14
README.md
@@ -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
6
bookmarks/middlewares.py
Normal 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
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
||||
|
127
bookmarks/static/bookmark_list.js
Normal file
127
bookmarks/static/bookmark_list.js
Normal 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();
|
||||
})()
|
@@ -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();
|
||||
})()
|
@@ -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;
|
||||
|
||||
|
@@ -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 %}
|
||||
|
@@ -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 %}
|
||||
|
@@ -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);
|
||||
})();
|
||||
|
@@ -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 %}
|
||||
|
@@ -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>
|
||||
|
46
bookmarks/tests/test_auth_proxy_support.py
Normal file
46
bookmarks/tests/test_auth_proxy_support.py
Normal 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')
|
@@ -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)
|
||||
|
||||
|
@@ -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})
|
||||
|
@@ -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',
|
||||
|
53
bookmarks/tests/test_context_path.py
Normal file
53
bookmarks/tests/test_context_path.py
Normal 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)
|
@@ -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]))
|
||||
|
@@ -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"
|
||||
|
@@ -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.
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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 = %(_)
|
||||
|
@@ -1 +1 @@
|
||||
1.13.0
|
||||
1.14.0
|
||||
|
Reference in New Issue
Block a user