Compare commits

...

11 Commits

Author SHA1 Message Date
Sascha Ißbrücker
aac8bf39b8 Bump version 2022-05-26 04:19:22 +02:00
Aaron Bach
49f648a908 Add community reference to linkding-cli (#270) 2022-05-26 04:16:06 +02:00
Sascha Ißbrücker
68c3c27b38 Add PATCH support to bookmarks endpoint (#269)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-26 04:15:13 +02:00
kenc
792a19d15e Allow creating archived bookmark through REST API (#268)
* Add POST archived API endpoint

* Update API docs

* Expose is_archived in existing POST endpoint

* Add test to verify bookmark not archived by default

* Fix JSON payload in API docs

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2022-05-26 04:10:36 +02:00
Sascha Ißbrücker
2de6d8151b Improve about section (#265)
* Improve about section

* Add changelog link

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-21 21:33:08 +02:00
Sascha Ißbrücker
9e9d7ae7d2 Add background tasks to admin (#264)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-21 18:21:10 +02:00
Sascha Ißbrücker
4e8a183082 Update CHANGELOG.md 2022-05-21 13:42:33 +02:00
Sascha Ißbrücker
7719c5b1ba Bump version 2022-05-21 13:26:28 +02:00
Sascha Ißbrücker
e08bf9fd03 Fake request headers to reduce bot detection (#263)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-05-21 13:25:32 +02:00
Sascha Ißbrücker
a9bf111ff1 Update CHANGELOG.md 2022-05-21 10:49:40 +02:00
Sascha Ißbrücker
54b0b32b80 Update SECURITY.md 2022-05-21 10:46:27 +02:00
15 changed files with 240 additions and 31 deletions

View File

@@ -1,5 +1,31 @@
# Changelog
## v1.10.1 (21/05/2022)
### What's Changed
* Fake request headers to reduce bot detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/263
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.0...v1.10.1
---
## v1.10.0 (21/05/2022)
### What's Changed
* Add to managed hosting options by @m3nu in https://github.com/sissbruecker/linkding/pull/253
* Add community reference to aiolinkding by @bachya in https://github.com/sissbruecker/linkding/pull/259
* Improve import performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/261
* Update how-to.md to fix unclear/paraphrased Safari action in IOS Shortcuts by @feoh in https://github.com/sissbruecker/linkding/pull/260
* Allow searching for untagged bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/226
### New Contributors
* @m3nu made their first contribution in https://github.com/sissbruecker/linkding/pull/253
* @bachya made their first contribution in https://github.com/sissbruecker/linkding/pull/259
* @feoh made their first contribution in https://github.com/sissbruecker/linkding/pull/260
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.9.0...v1.10.0
---
## v1.9.0 (14/05/2022)
### What's Changed
* Scroll menu items into view when using keyboard by @sissbruecker in https://github.com/sissbruecker/linkding/pull/248
@@ -171,13 +197,3 @@
## v1.2.0 (09/01/2021)
- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)
- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)
---
## v1.1.1 (01/01/2021)
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
---
## v1.1.0 (31/12/2020)
- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
- [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50)

View File

@@ -129,6 +129,7 @@ This section lists community projects around using linkding, in alphabetical ord
- [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)
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
## Development

View File

@@ -4,7 +4,7 @@
| Version | Supported |
| ------- | ------------------ |
| 1.9.x | :white_check_mark: |
| 1.10.x | :white_check_mark: |
## Reporting a Vulnerability

View File

@@ -1,3 +1,5 @@
from background_task.admin import TaskAdmin, CompletedTaskAdmin
from background_task.models import Task, CompletedTask
from django.contrib import admin, messages
from django.contrib.admin import AdminSite
from django.contrib.auth.admin import UserAdmin
@@ -107,3 +109,5 @@ linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast)
linkding_admin_site.register(Task, TaskAdmin)
linkding_admin_site.register(CompletedTask, CompletedTaskAdmin)

View File

@@ -19,6 +19,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
'description',
'website_title',
'website_description',
'is_archived',
'tag_names',
'date_added',
'date_modified'
@@ -33,6 +34,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default='')
description = serializers.CharField(required=False, allow_blank=True, default='')
is_archived = serializers.BooleanField(required=False, default=False)
# Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField(required=False, default=[])
@@ -41,14 +43,21 @@ class BookmarkSerializer(serializers.ModelSerializer):
bookmark.url = validated_data['url']
bookmark.title = validated_data['title']
bookmark.description = validated_data['description']
bookmark.is_archived = validated_data['is_archived']
tag_string = build_tag_string(validated_data['tag_names'])
return create_bookmark(bookmark, tag_string, self.context['user'])
def update(self, instance: Bookmark, validated_data):
instance.url = validated_data['url']
instance.title = validated_data['title']
instance.description = validated_data['description']
tag_string = build_tag_string(validated_data['tag_names'])
# Update fields if they were provided in the payload
for key in ['url', 'title', 'description']:
if key in validated_data:
setattr(instance, key, validated_data[key])
# Use tag string from payload, or use bookmark's current tags as fallback
tag_string = build_tag_string(instance.tag_names)
if 'tag_names' in validated_data:
tag_string = build_tag_string(validated_data['tag_names'])
return update_bookmark(instance, tag_string, self.context['user'])

View File

@@ -34,7 +34,8 @@ def load_website_metadata(url: str):
def load_page(url: str):
r = requests.get(url, timeout=10)
headers = fake_request_headers()
r = requests.get(url, timeout=10, headers=headers)
# Use charset_normalizer to determine encoding that best matches the response content
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
@@ -42,3 +43,13 @@ def load_page(url: str):
# before trying to determine one
results = from_bytes(r.content)
return str(results.best())
def fake_request_headers():
return {
"Accept": "text/html,application/xhtml+xml,application/xml",
"Accept-Encoding": "gzip, deflate",
"Dnt": "1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36",
}

View File

@@ -11,4 +11,8 @@
.input-group > input[type=submit] {
height: auto;
}
section.about table {
max-width: 500px;
}
}

View File

@@ -98,13 +98,29 @@
</section>
{# About section #}
<section class="content-area">
<section class="content-area about">
<h2>About</h2>
<p>Version: {{ app_version }}</p>
<p>
Code: <a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a>
</p>
<table class="table">
<tbody>
<tr>
<td>Version</td>
<td>{{ version_info }}</td>
</tr>
<tr>
<td rowspan="3" style="vertical-align: top">Links</td>
<td><a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding#documentation"
target="_blank">Documentation</a></td>
</tr>
<tr>
<td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
target="_blank">Changelog</a></td>
</tr>
</tbody>
</table>
</section>
</div>

View File

@@ -83,6 +83,11 @@ class LinkdingApiTestCase(APITestCase):
self.assertEqual(response.status_code, expected_status_code)
return response
def patch(self, url, data=None, expected_status_code=status.HTTP_200_OK):
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, expected_status_code)
return response
def delete(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.delete(url)
self.assertEqual(response.status_code, expected_status_code)

View File

@@ -34,6 +34,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation['description'] = bookmark.description
expectation['website_title'] = bookmark.website_title
expectation['website_description'] = bookmark.website_description
expectation['is_archived'] = bookmark.is_archived
expectation['tag_names'] = tag_names
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z')
@@ -49,6 +50,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'is_archived': False,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
@@ -56,6 +58,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
self.assertFalse(bookmark.is_archived, data['is_archived'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
@@ -92,6 +95,30 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
def test_create_archived_bookmark(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'is_archived': True,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
self.assertTrue(bookmark.is_archived)
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_minimal_payload_does_not_archive(self):
data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.is_archived)
def test_get_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
@@ -104,6 +131,56 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, data['url'])
def test_update_bookmark_fails_without_required_fields(self):
data = {'title': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, data['url'])
self.assertEqual(updated_bookmark.title, '')
self.assertEqual(updated_bookmark.description, '')
self.assertEqual(updated_bookmark.tag_names, [])
def test_patch_bookmark(self):
data = {'url': 'https://example.com'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.url, data['url'])
data = {'title': 'Updated title'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.title, data['title'])
data = {'description': 'Updated description'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
self.assertEqual(self.bookmark1.description, data['description'])
data = {'tag_names': ['updated-tag-1', 'updated-tag-2']}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db()
tag_names = [tag.name for tag in self.bookmark1.tags.all()]
self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2'])
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.url, self.bookmark1.url)
self.assertEqual(updated_bookmark.title, self.bookmark1.title)
self.assertEqual(updated_bookmark.description, self.bookmark1.description)
self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names)
def test_delete_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -1,8 +1,14 @@
import random
from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch, Mock
import requests
from requests import RequestException
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info
class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
@@ -38,3 +44,31 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.bookmark_date_display, form_data['bookmark_date_display'])
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'])
def test_about_shows_version_info(self):
response = self.client.get(reverse('bookmarks:settings.general'))
html = response.content.decode()
self.assertInHTML(f'''
<tr>
<td>Version</td>
<td>{get_version_info(random.random())}</td>
</tr>
''', html)
def test_get_version_info_just_displays_latest_when_versions_are_equal(self):
latest_version_response_mock = Mock(status_code=201, json=lambda: {'name': f'v{app_version}'})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version} (latest)')
def test_get_version_info_shows_latest_version_when_versions_are_not_equal(self):
latest_version_response_mock = Mock(status_code=201, json=lambda: {'name': f'v123.0.1'})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version} (latest: 123.0.1)')
def test_get_version_info_silently_ignores_request_errors(self):
with patch.object(requests, 'get', side_effect=RequestException()):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version}')

View File

@@ -1,5 +1,8 @@
import logging
import time
from functools import lru_cache
import requests
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect, HttpResponse
@@ -17,10 +20,11 @@ logger = logging.getLogger(__name__)
try:
with open("version.txt", "r") as f:
app_version = f.read().strip("\n")
except Exception as exc:
except Exception as exc:
logging.exception(exc)
pass
@login_required
def general(request):
if request.method == 'POST':
@@ -32,14 +36,41 @@ def general(request):
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
version_info = get_version_info(get_ttl_hash())
return render(request, 'settings/general.html', {
'form': form,
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
'app_version': app_version
'version_info': version_info,
})
# Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1)
def get_version_info(ttl_hash=None):
latest_version = None
try:
latest_version_url = 'https://api.github.com/repos/sissbruecker/linkding/releases/latest'
response = requests.get(latest_version_url, timeout=5)
json = response.json()
latest_version = json['name'][1:]
except requests.exceptions.RequestException:
pass
latest_version_info = ''
if latest_version == app_version:
latest_version_info = ' (latest)'
elif latest_version is not None:
latest_version_info = f' (latest: {latest_version})'
return f'{app_version}{latest_version_info}'
def get_ttl_hash(seconds=3600):
"""Return the same value within `seconds` time period"""
return round(time.time() / seconds)
@login_required
def integrations(request):
application_url = request.build_absolute_uri("/bookmarks/new")

View File

@@ -24,7 +24,7 @@ The following resources are available:
GET /api/bookmarks/
```
List bookmarks.
List bookmarks.
Parameters:
@@ -65,7 +65,7 @@ Example response:
GET /api/bookmarks/archived/
```
List archived bookmarks.
List archived bookmarks.
Parameters and response are the same as for the regular list endpoint.
@@ -83,7 +83,8 @@ Retrieves a single bookmark by ID.
POST /api/bookmarks/
```
Creates a new bookmark. Tags are simply assigned using their names.
Creates a new bookmark. Tags are simply assigned using their names. Including
`is_archived: true` saves a bookmark directly to the archive.
Example payload:
@@ -92,6 +93,7 @@ Example payload:
"url": "https://example.com",
"title": "Example title",
"description": "Example description",
"is_archived": false,
"tag_names": [
"tag1",
"tag2"
@@ -201,4 +203,3 @@ Example payload:
"name": "example"
}
```

View File

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

View File

@@ -1 +1 @@
1.10.0
1.11.0