Compare commits

...

24 Commits

Author SHA1 Message Date
Sascha Ißbrücker
62d7fb5f63 Bump version 2023-01-20 21:28:51 +01:00
dependabot[bot]
fa2633147a Bump minimatch from 3.0.4 to 3.1.2 (#366)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.0.4 to 3.1.2.
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-20 21:18:55 +01:00
dependabot[bot]
ddf97b0a3f Bump certifi from 2022.6.15 to 2022.12.7 (#374)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.6.15 to 2022.12.7.
- [Release notes](https://github.com/certifi/python-certifi/releases)
- [Commits](https://github.com/certifi/python-certifi/compare/2022.06.15...2022.12.07)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-20 21:08:52 +01:00
dependabot[bot]
d3b4aa7602 Bump django from 4.1 to 4.1.2 (#391)
Bumps [django](https://github.com/django/django) from 4.1 to 4.1.2.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/4.1...4.1.2)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-20 21:04:30 +01:00
Sascha Ißbrücker
021d1cd673 Fix bookmark website metadata not being updated when URL changes (#400) 2023-01-20 20:59:09 +01:00
Sascha Ißbrücker
43d52642a6 Fix website loader test 2023-01-14 12:26:04 +01:00
Sascha Ißbrücker
4f9170c48d Improve website loader logging 2023-01-14 11:24:09 +01:00
Sascha Ißbrücker
313a0ee99f Update CHANGELOG.md 2023-01-12 21:34:36 +01:00
Sascha Ißbrücker
4e32bafe89 Bump version 2023-01-12 21:16:44 +01:00
Sascha Ißbrücker
035399442a Pin node docker image version 2023-01-12 21:16:28 +01:00
Luca
c2d8cde86b Trim website metadata title and description (#383)
* feat: trim fetched metadata placeholders

* feat: implement trimming serverside

* Add website loader tests

* Address review comments

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2023-01-12 21:06:36 +01:00
tomamplius
13e0516961 Add postgres as database engine (#388)
* Add postgres as database engine

* Fix sissbruecker review

* replace psycopg2 by psycopg2-binary

* Fix Docker setup

* Polish docs

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2023-01-12 21:00:58 +01:00
McKenna Jones
7b03ceab98 Gracefully stop docker container when it receives SIGTERM (#368)
* add die-on-term option to uwsgi

* exec uwsgi in bootstrap.sh
2023-01-07 19:49:11 +01:00
Alexander Sulfrian
fee979a371 Only show admin link for superusers (#384) 2023-01-06 19:39:47 +01:00
Kazi
9eaae1fcf5 Android HTTP shortcuts v3 (#387)
This simplifies tagging and also filters URL's shared from apps. Useful for apps that don't share clean URLs

Co-authored-by: Roland Meyer <2445563+Waboodoo@users.noreply.github.com>
2023-01-06 19:12:06 +01:00
jhauris
3abdd92430 Correct LD_ENABLE_AUTH_PROXY documentation (#372) (#379) 2022-12-21 08:53:34 +01:00
jhauris
b99d7bf1cc Add apache reverse proxy documentation. (#371)
* Add apache reverse proxy documentation

* Add link to apache2 configuration
2022-12-21 08:52:38 +01:00
Sascha Ißbrücker
f84e2d2210 Add error handling for checking latest version (#360) 2022-10-16 13:04:36 +02:00
Sascha Ißbrücker
2fd7704816 Limit document size for website scraper (#354)
Limits the size of scraped HTML documents to prevent out of memory errors. The scraper will stop reading from the response when it encounters the closing head tag, or if the read content's size exceeds a max limit.

Fixes #345
2022-10-07 21:18:18 +02:00
Sascha Ißbrücker
277c1c76e3 Use raw URL for shortcut JSON 2022-10-06 21:23:23 +02:00
Sascha Ißbrücker
2787dcb769 Bump version 2022-10-05 10:07:40 +02:00
Sascha Ißbrücker
1c3651e91d Add setting and documentation for fixing CSRF errors (#349)
* Add documentation and setting for solving CSRF errors

* Improve proxy setup docs

* Link to reverse proxy documentation

* Fix link
2022-10-05 10:01:44 +02:00
Sascha Ißbrücker
53be77aade Fix static file dir warning (#350) 2022-10-05 10:00:13 +02:00
Sascha Ißbrücker
7148bc62c3 Update CHANGELOG.md 2022-09-11 08:23:04 +02:00
27 changed files with 530 additions and 73 deletions

View File

@@ -4,10 +4,10 @@ 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=
# Username of the initial superuser to create, leave empty to not create one
LD_SUPERUSER_NAME=
# Password for the initial superuser, leave empty to disable credentials authentication and rely on proxy authentication instead
@@ -24,3 +24,24 @@ LD_AUTH_PROXY_USERNAME_HEADER=
# The URL that linkding should redirect to after a logout, when using an auth proxy
# See docs/Options.md for more details
LD_AUTH_PROXY_LOGOUT_URL=
# List of trusted origins from which to accept POST requests
# See docs/Options.md for more details
LD_CSRF_TRUSTED_ORIGINS=
# Database settings
# These are currently only required for configuring PostreSQL.
# By default, linkding uses SQLite for which you don't need to configure anything.
# Database engine, can be sqlite (default) or postgres
LD_DB_ENGINE=
# Database name (default: linkding)
LD_DB_DATABASE=
# Username to connect to the database server (default: linkding)
LD_DB_USER=
# Password to connect to the database server
LD_DB_PASSWORD=
# The hostname where the database is hosted (default: localhost)
LD_DB_HOST=
# Port use to connect to the database server
# Should use the default port if not set
LD_DB_PORT=

4
.gitignore vendored
View File

@@ -182,7 +182,9 @@ typings/
### Custom
# Rollup compilation output
/build
/bookmarks/static/bundle.js*
# SASS compilation output
/bookmarks/static/theme-*.css*
# Collected static files for deployment
/static
# Build output, etc.

View File

@@ -1,5 +1,57 @@
# Changelog
## v1.16.0 (12/01/2023)
### What's Changed
* Add postgres as database engine by @tomamplius in https://github.com/sissbruecker/linkding/pull/388
* Gracefully stop docker container when it receives SIGTERM by @mckennajones in https://github.com/sissbruecker/linkding/pull/368
* Limit document size for website scraper by @sissbruecker in https://github.com/sissbruecker/linkding/pull/354
* Add error handling for checking latest version by @sissbruecker in https://github.com/sissbruecker/linkding/pull/360
* Trim website metadata title and description by @luca1197 in https://github.com/sissbruecker/linkding/pull/383
* Only show admin link for superusers by @AlexanderS in https://github.com/sissbruecker/linkding/pull/384
* Add apache reverse proxy documentation. by @jhauris in https://github.com/sissbruecker/linkding/pull/371
* Correct LD_ENABLE_AUTH_PROXY documentation by @jhauris in https://github.com/sissbruecker/linkding/pull/379
* Android HTTP shortcuts v3 by @kzshantonu in https://github.com/sissbruecker/linkding/pull/387
### New Contributors
* @jhauris made their first contribution in https://github.com/sissbruecker/linkding/pull/371
* @AlexanderS made their first contribution in https://github.com/sissbruecker/linkding/pull/384
* @mckennajones made their first contribution in https://github.com/sissbruecker/linkding/pull/368
* @tomamplius made their first contribution in https://github.com/sissbruecker/linkding/pull/388
* @luca1197 made their first contribution in https://github.com/sissbruecker/linkding/pull/383
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.1...v1.16.0
---
## v1.15.1 (05/10/2022)
### What's Changed
* Fix static file dir warning by @sissbruecker in https://github.com/sissbruecker/linkding/pull/350
* Add setting and documentation for fixing CSRF errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/349
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.0...v1.15.1
---
## v1.15.0 (11/09/2022)
### What's Changed
* Bump Django and other dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/331
* Add option to create initial superuser by @sissbruecker in https://github.com/sissbruecker/linkding/pull/323
* Improved Android HTTP Shortcuts doc by @kzshantonu in https://github.com/sissbruecker/linkding/pull/330
* Minify bookmark list HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/332
* Bump python version to 3.10 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/333
* Fix error when deleting all bookmarks in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/336
* Improve bookmark query performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/334
* Prevent rate limit errors in wayback machine API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/339
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.14.0...v1.15.0
---
## v1.14.0 (14/08/2022)
### What's Changed

View File

@@ -1,4 +1,4 @@
FROM node:current-alpine AS node-build
FROM node:18.13.0-alpine AS node-build
WORKDIR /etc/linkding
# install build dependencies
COPY package.json package-lock.json ./
@@ -10,7 +10,7 @@ RUN npm run build
FROM python:3.10.6-slim-buster AS python-base
RUN apt-get update && apt-get -y install build-essential
RUN apt-get update && apt-get -y install build-essential libpq-dev
WORKDIR /etc/linkding
@@ -34,7 +34,7 @@ RUN mkdir /opt/venv && \
FROM python:3.10.6-slim-buster as final
RUN apt-get update && apt-get -y install mime-support
RUN apt-get update && apt-get -y install mime-support libpq-dev
WORKDIR /etc/linkding
# copy prod dependencies
COPY --from=prod-deps /opt/venv /opt/venv

View File

@@ -12,6 +12,7 @@
- [Using Docker](#using-docker)
- [Using Docker Compose](#using-docker-compose)
- [User Setup](#user-setup)
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Managed Hosting Options](#managed-hosting-options)
- [Documentation](#documentation)
- [Browser Extension](#browser-extension)
@@ -52,7 +53,11 @@ The name comes from:
## Installation
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/). The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
By default, linkding uses SQLite as a database.
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
### Using Docker
@@ -96,6 +101,61 @@ docker-compose exec linkding python manage.py createsuperuser --username=joe --e
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
### Reverse Proxy Setup
When using a reverse proxy, such as Nginx or Apache, you may need to configure your proxy to correctly forward the `Host` header to linkding, otherwise certain requests, such as login, might fail.
<details>
<summary>Apache</summary>
Apache2 does not change the headers by default, and should not
need additional configuration.
An example virtual host that proxies to linkding might look like:
```
<VirtualHost *:9100>
<Proxy *>
Order deny,allow
Allow from all
</Proxy>
ProxyPass / http://linkding:9090/
ProxyPassReverse / http://linkding:9090/
</VirtualHost>
```
For a full example, see the docker-compose configuration in [jhauris/apache2-reverse-proxy](https://github.com/jhauris/linkding/tree/apache2-reverse-proxy)
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
</details>
<details>
<summary>Caddy 2</summary>
Caddy does not change the headers by default, and should not need any further configuration.
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
</details>
<details>
<summary>Nginx</summary>
Nginx by default rewrites the `Host` header to whatever URL is used in the `proxy_pass` directive.
To forward the correct headers to linkding, add the following directives to the location block of your Nginx config:
```
location /linkding {
...
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
</details>
Instead of configuring header forwarding in your proxy, you can also configure the URL from which you want to access your linkding instance with the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
### Managed Hosting Options
Self-hosting web applications on your own hardware (unfortunately) still requires a lot of technical know-how, and commitment to maintenance, with regard to keeping everything up-to-date and secure. This can be a huge entry barrier for people who are interested in self-hosting linkding, but lack the technical knowledge to do so. This section is intended to provide alternatives in form of managed hosting solutions. Note that these options are usually commercial offerings, that require paying a (usually monthly) fee for the convenience of being managed by another party. The technical knowledge required to make use of individual options is going to vary, and no guarantees can be made that every option is accessible for everyone. That being said, I hope this section helps in making the application accessible to a wider audience.

View File

@@ -48,6 +48,7 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
tasks.create_web_archive_snapshot(current_user, bookmark, True)
# Only update website metadata if URL changed
_update_website_metadata(bookmark)
bookmark.save()
return bookmark

View File

@@ -1,8 +1,12 @@
import logging
from dataclasses import dataclass
import requests
from bs4 import BeautifulSoup
from charset_normalizer import from_bytes
from django.utils import timezone
logger = logging.getLogger(__name__)
@dataclass
@@ -23,25 +27,61 @@ def load_website_metadata(url: str):
title = None
description = None
try:
start = timezone.now()
page_text = load_page(url)
end = timezone.now()
logger.debug(f'Load duration: {end - start}')
start = timezone.now()
soup = BeautifulSoup(page_text, 'html.parser')
title = soup.title.string if soup.title is not None else None
title = soup.title.string.strip() if soup.title is not None else None
description_tag = soup.find('meta', attrs={'name': 'description'})
description = description_tag['content'] if description_tag is not None else None
description = description = description_tag['content'].strip() if description_tag and description_tag[
'content'] else None
end = timezone.now()
logger.debug(f'Parsing duration: {end - start}')
finally:
return WebsiteMetadata(url=url, title=title, description=description)
CHUNK_SIZE = 50 * 1024
MAX_CONTENT_LIMIT = 5000 * 1024
def load_page(url: str):
headers = fake_request_headers()
r = requests.get(url, timeout=10, headers=headers)
size = 0
content = None
iteration = 0
# Use with to ensure request gets closed even if it's only read partially
with requests.get(url, timeout=10, headers=headers, stream=True) as r:
for chunk in r.iter_content(chunk_size=CHUNK_SIZE):
size += len(chunk)
iteration = iteration + 1
if content is None:
content = chunk
else:
content = content + chunk
logger.debug(f'Loaded chunk (iteration={iteration}, total={size / 1024})')
# Stop reading if we have parsed end of head tag
if '</head>'.encode('utf-8') in content:
logger.debug(f'Found closing head tag after {size} bytes')
break
# Stop reading if we exceed limit
if size > MAX_CONTENT_LIMIT:
logger.debug(f'Cancel reading document after {size} bytes')
break
if hasattr(r, '_content_consumed'):
logger.debug(f'Request consumed: {r._content_consumed}')
# Use charset_normalizer to determine encoding that best matches the response content
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
# This is different from Response.text which does respect the encoding specified in the response first,
# before trying to determine one
results = from_bytes(r.content)
results = from_bytes(content or '')
return str(results.best())

View File

@@ -9,6 +9,7 @@
<li class="tab-item {% if request.get_full_path == integrations_url %}active{% endif %}">
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
</li>
{% if request.user.is_superuser %}
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features &#010; such as user management and bulk operations.">
<a href="{% url 'admin:index' %}" target="_blank">
<span>Admin</span>
@@ -18,5 +19,6 @@
</svg>
</a>
</li>
{% endif %}
</ul>
<br>

View File

@@ -0,0 +1,29 @@
import importlib
import os
from unittest import mock
from django.test import TestCase
class AppOptionsTestCase(TestCase):
def setUp(self) -> None:
self.settings_module = importlib.import_module('siteroot.settings.base')
def test_empty_csrf_trusted_origins(self):
module = importlib.reload(self.settings_module)
self.assertFalse(hasattr(module, 'CSRF_TRUSTED_ORIGINS'))
@mock.patch.dict(os.environ, {'LD_CSRF_TRUSTED_ORIGINS': 'https://linkding.example.com'})
def test_single_csrf_trusted_origin(self):
module = importlib.reload(self.settings_module)
self.assertTrue(hasattr(module, 'CSRF_TRUSTED_ORIGINS'))
self.assertCountEqual(module.CSRF_TRUSTED_ORIGINS, ['https://linkding.example.com'])
@mock.patch.dict(os.environ, {'LD_CSRF_TRUSTED_ORIGINS': 'https://linkding.example.com,http://linkding.example.com'})
def test_multiple_csrf_trusted_origin(self):
module = importlib.reload(self.settings_module)
self.assertTrue(hasattr(module, 'CSRF_TRUSTED_ORIGINS'))
self.assertCountEqual(module.CSRF_TRUSTED_ORIGINS, ['https://linkding.example.com', 'http://linkding.example.com'])

View File

@@ -5,11 +5,12 @@ from django.test import TestCase
from django.utils import timezone
from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.services import website_loader
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.services import tasks
User = get_user_model()
@@ -19,6 +20,27 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.get_or_create_test_user()
def test_create_should_update_website_metadata(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
'https://example.com',
'Website title',
'Website description'
)
mock_load_website_metadata.return_value = expected_metadata
bookmark_data = Bookmark(url='https://example.com',
title='Updated Title',
description='Updated description',
unread=True,
shared=True,
is_archived=True)
created_bookmark = create_bookmark(bookmark_data, '', self.get_or_create_test_user())
created_bookmark.refresh_from_db()
self.assertEqual(expected_metadata.title, created_bookmark.website_title)
self.assertEqual(expected_metadata.description, created_bookmark.website_description)
def test_create_should_update_existing_bookmark_with_same_url(self):
original_bookmark = self.setup_bookmark(url='https://example.com', unread=False, shared=False)
bookmark_data = Bookmark(url='https://example.com',
@@ -63,11 +85,21 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def test_update_should_update_website_metadata_if_url_did_change(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
'https://example.com/updated',
'Updated website title',
'Updated website description'
)
mock_load_website_metadata.return_value = expected_metadata
bookmark = self.setup_bookmark()
bookmark.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user)
bookmark.refresh_from_db()
mock_load_website_metadata.assert_called_once()
self.assertEqual(expected_metadata.title, bookmark.website_title)
self.assertEqual(expected_metadata.description, bookmark.website_description)
def test_update_should_not_update_website_metadata_if_url_did_not_change(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:

View File

@@ -59,13 +59,13 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
''', 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}'})
latest_version_response_mock = Mock(status_code=200, 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'})
latest_version_response_mock = Mock(status_code=200, 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)')
@@ -74,3 +74,14 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
with patch.object(requests, 'get', side_effect=RequestException()):
version_info = get_version_info(random.random())
self.assertEqual(version_info, f'{app_version}')
def test_get_version_info_handles_invalid_response(self):
latest_version_response_mock = Mock(status_code=403, json=lambda: {})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, app_version)
latest_version_response_mock = Mock(status_code=200, json=lambda: {})
with patch.object(requests, 'get', return_value=latest_version_response_mock):
version_info = get_version_info(random.random())
self.assertEqual(version_info, app_version)

View File

@@ -0,0 +1,80 @@
from unittest import mock
from bookmarks.services import website_loader
from django.test import TestCase
class MockStreamingResponse:
def __init__(self, num_chunks, chunk_size, insert_head_after_chunk=None):
self.chunks = []
for index in range(num_chunks):
chunk = ''.zfill(chunk_size)
self.chunks.append(chunk.encode('utf-8'))
if index == insert_head_after_chunk:
self.chunks.append('</head>'.encode('utf-8'))
def iter_content(self, **kwargs):
return self.chunks
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
pass
class WebsiteLoaderTestCase(TestCase):
def render_html_document(self, title, description):
return f'''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{title}</title>
<meta name="description" content="{description}">
</head>
<body></body>
</html>
'''
def test_load_page_returns_content(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = MockStreamingResponse(num_chunks=10, chunk_size=1024)
content = website_loader.load_page('https://example.com')
expected_content_size = 10 * 1024
self.assertEqual(expected_content_size, len(content))
def test_load_page_limits_large_documents(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = MockStreamingResponse(num_chunks=10, chunk_size=1024 * 1000)
content = website_loader.load_page('https://example.com')
# Should have read six chunks, after which content exceeds the max of 5MB
expected_content_size = 6 * 1024 * 1000
self.assertEqual(expected_content_size, len(content))
def test_load_page_stops_reading_at_closing_head_tag(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = MockStreamingResponse(num_chunks=10, chunk_size=1024 * 1000,
insert_head_after_chunk=0)
content = website_loader.load_page('https://example.com')
# Should have read first chunk, and second chunk containing closing head tag
expected_content_size = 1 * 1024 * 1000 + len('</head>')
self.assertEqual(expected_content_size, len(content))
def test_load_website_metadata(self):
with mock.patch('bookmarks.services.website_loader.load_page') as mock_load_page:
mock_load_page.return_value = self.render_html_document('test title', 'test description')
metadata = website_loader.load_website_metadata('https://example.com')
self.assertEqual('test title', metadata.title)
self.assertEqual('test description', metadata.description)
def test_load_website_metadata_trims_title_and_description(self):
with mock.patch('bookmarks.services.website_loader.load_page') as mock_load_page:
mock_load_page.return_value = self.render_html_document(' test title ', ' test description ')
metadata = website_loader.load_website_metadata('https://example.com')
self.assertEqual('test title', metadata.title)
self.assertEqual('test description', metadata.description)

View File

@@ -54,7 +54,8 @@ def get_version_info(ttl_hash=None):
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:]
if response.status_code == 200 and 'name' in json:
latest_version = json['name'][1:]
except requests.exceptions.RequestException:
pass

View File

@@ -22,4 +22,4 @@ if [ "$LD_DISABLE_BACKGROUND_TASKS" != "True" ]; then
fi
# Start uwsgi server
uwsgi --http :$LD_SERVER_PORT uwsgi.ini
exec uwsgi --http :$LD_SERVER_PORT uwsgi.ini

View File

@@ -83,7 +83,7 @@ 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.
Note that this automatically creates new users in the database if they do not already exist.
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.
@@ -93,3 +93,59 @@ For example, for Authelia, which passes the `Remote-User` HTTP header, the `LD_A
- `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.
### `LD_CSRF_TRUSTED_ORIGINS`
Values: `String` | Default = None
List of trusted origins / host names to allow for `POST` requests, for example when logging in, or saving bookmarks.
For these type of requests, the `Origin` header must match the `Host` header, otherwise the request will fail with a `403` status code, and the message `CSRF verification failed.`
This option allows to declare a list of trusted origins that will be accepted even if the headers do not match. This can be the case when using a reverse proxy that rewrites the `Host` header, such as Nginx.
For example, to allow requests to https://linkding.mydomain.com, configure the setting to `https://linkding.mydomain.com`.
Note that the setting **must** include the correct protocol (`https` or `http`), and **must not** include the application / context path.
Multiple origins can be specified by separating them with a comma (`,`).
This setting is adopted from the Django framework used by linkding, more information on the setting is available in the [Django documentation](https://docs.djangoproject.com/en/4.0/ref/settings/#std-setting-CSRF_TRUSTED_ORIGINS).
### `LD_DB_ENGINE`
Values: `postgres` or `sqlite` | Default = `sqlite`
Database engine used by linkding to store data.
Currently, linkding supports SQLite and PostgreSQL.
By default, linkding uses SQLite, for which you don't need to configure anything.
All the other database variables below are only required for configured PostgresSQL.
### `LD_DB_DATABASE`
Values: `String` | Default = `linkding`
The name of the database.
### `LD_DB_USER`
Values: `String` | Default = `linkding`
The name of the user to connect to the database server.
### `LD_DB_PASSWORD`
Values: `String` | Default = None
The password of the user to connect to the database server.
The password must be configured when using a database other than SQLite, there is no default value.
### `LD_DB_HOST`
Values: `String` | Default = `localhost`
The hostname or IP of the database server.
### `LD_DB_PORT`
Values: `Integer` | Default = None
The port of the database server.
Should use the default port if left empty, for example `5432` for PostgresSQL.

View File

@@ -26,13 +26,13 @@ For more info see here: https://paul.kinlan.me/use-bookmarklets-on-chrome-on-and
- Install HTTP Shortcuts from [Play Store](https://play.google.com/store/apps/details?id=ch.rmy.android.http_shortcuts) or [F-Droid](https://f-droid.org/en/packages/ch.rmy.android.http_shortcuts/).
- Copy the raw URL of [linkding_shortcut.json](/docs/linkding_shortcut.json) in this repository.
- Copy the URL of [linkding_shortcut.json](https://raw.githubusercontent.com/sissbruecker/linkding/master/docs/linkding_shortcut.json).
- Open HTTP Shortcuts, tap the 3-dot-button at the top-right corner, tap `Import/Export`, then tap `Import from URL`.
- Paste the URL you copied earlier, tap OK, go back, tap the 3-dot-button again, then tap `Variables`.
- Edit the `values` of `linkding_instance` and `linkding_api_token`.
- Edit the `values` of `linkding_instance` and `linkding_api_key`.
Try using share button on an app, a new item `Send to...` should appear on the share sheet. You can also manually share by tapping the shortcut inside the HTTP Shortcuts app itself.

View File

@@ -1,60 +1,95 @@
{
"categories": [
{
"id": "8f4299d4-4c30-4a8e-a3f9-c90694011713",
"id": "e260b423-db01-4743-a671-2cd38594c63c",
"layoutType": "wide_grid",
"name": "Shortcuts",
"shortcuts": [
{
"bodyContent": "{ \"url\": \"{{b2953f61-b302-4c79-b90d-39858a06d9a6}}\", \"tag_names\": [ \"{{7871474b-e325-4ca0-a142-a5ef5d3f7ed8}}\" ] }",
"bodyContent": "{{7b26d228-4ad6-4b1c-8b7b-076dc03385cc}}",
"codeOnPrepare": "const sharedValue \u003d getVariable(\u0027text_and_url\u0027)\nconst matches \u003d sharedValue.match(/\\bhttps?:\\/\\/\\S+/gi);\nconst url \u003d matches[0];\nsetVariable(\u0027cleaned_url\u0027, url);",
"contentType": "application/json",
"description": "Bookmark to linkding",
"description": "bookmark link",
"headers": [
{
"id": "fd6306d7-e09d-4c14-a538-3fc258460028",
"id": "b66dd9b9-13e8-4802-b527-6e32f3980f4b",
"key": "Authorization",
"value": "Token {{6a739a16-d16d-4a06-93a5-3457da3c3d20}}"
"value": "Token {{908e3a30-ae82-400d-93c8-561c36d11d6d}}"
}
],
"iconName": "flat_grey_ribbon",
"id": "1e047d02-a4a3-4cad-b4cc-123cc16c8398",
"launcherShortcut": true,
"iconName": "flat_grey_pin",
"id": "871c3219-9e9f-46bb-8a7f-78f1496f78fc",
"method": "POST",
"name": "Linkding",
"quickSettingsTileShortcut": true,
"responseHandling": {
"failureOutput": "simple",
"uiType": "toast"
},
"url": "{{ea2db14b-b9ca-45d8-8555-403271a38f5a}}/api/bookmarks/"
"url": "{{26253fe2-d202-4ce8-acd1-55c1ad3ae7d1}}/api/bookmarks/"
}
]
}
],
"variables": [
{
"id": "ea2db14b-b9ca-45d8-8555-403271a38f5a",
"id": "26253fe2-d202-4ce8-acd1-55c1ad3ae7d1",
"key": "linkding_instance",
"value": "https://your.instance.tld.without.slashed.end"
"value": "https://your.linkding.host.no.slashed.end"
},
{
"id": "a3c8efa2-3e3a-4bb4-8919-3e831f95fe6a",
"jsonEncode": true,
"key": "linkding_tag",
"message": "Comma separated",
"title": "One or more tags",
"type": "text"
},
{
"id": "908e3a30-ae82-400d-93c8-561c36d11d6d",
"key": "linkding_api_key",
"value": "your_api_key_here"
},
{
"id": "d76696e7-1ee1-4d98-b6f9-b570ec69ef40",
"key": "cleaned_url"
},
{
"flags": 1,
"id": "b2953f61-b302-4c79-b90d-39858a06d9a6",
"key": "linkding_add_url",
"title": "Enter URL",
"id": "da66cdad-8118-4a87-9581-4db33852b610",
"key": "text_and_url",
"message": "Any text that contains one URL",
"title": "URL",
"type": "text"
},
{
"id": "6a739a16-d16d-4a06-93a5-3457da3c3d20",
"key": "linkding_api_token",
"value": "your_token_from_integrations_tab"
"data": "{\"select\":{\"multi_select\":\"false\",\"separator\":\",\"}}",
"id": "7b26d228-4ad6-4b1c-8b7b-076dc03385cc",
"key": "tag_yes_no_default",
"options": [
{
"id": "9365e43e-0572-4621-ac06-caec1ccff09d",
"label": "Tagged",
"value": "{{5be61e61-d8f5-475b-b1b1-88ddaebf8fd5}}"
},
{
"id": "9f1caeaf-af57-42b4-8b10-4391354ad0f0",
"label": "Untagged and unread",
"value": "{{71ac9c4d-c03e-4b6f-ad75-9c112a591c50}}"
}
],
"title": "Tagged or unread?",
"type": "select"
},
{
"id": "7871474b-e325-4ca0-a142-a5ef5d3f7ed8",
"key": "linkding_custom_tag",
"message": "Enter one or more comma separated tags",
"title": "Tag",
"type": "text"
"id": "5be61e61-d8f5-475b-b1b1-88ddaebf8fd5",
"key": "request_body_tagged",
"value": "{ \"url\": \"{{d76696e7-1ee1-4d98-b6f9-b570ec69ef40}}\", \"tag_names\": [ \"{{a3c8efa2-3e3a-4bb4-8919-3e831f95fe6a}}\" ] }"
},
{
"id": "71ac9c4d-c03e-4b6f-ad75-9c112a591c50",
"key": "request_body_untagged",
"value": "{ \"url\": \"{{d76696e7-1ee1-4d98-b6f9-b570ec69ef40}}\", \"unread\": true }"
}
],
"version": 53
}
"version": 56
}

View File

@@ -1,5 +1,13 @@
# Troubleshooting
## Login fails with `403 CSRF verfication failed`
This can be the case when using a reverse proxy that rewrites the `Host` header, such as Nginx.
Since linkding version 1.15, the application includes a CSRF check that verifies that the `Origin` request header matches the `Host` header.
If the `Host` header is modified by the reverse proxy then this check fails.
To fix this, check the [reverse proxy setup documentation](../README.md#reverse-proxy-setup) on how to configure header forwarding for your proxy server, or alternatively configure the [`LD_CSRF_TRUSTED_ORIGINS` option](Options.md#LD_CSRF_TRUSTED_ORIGINS) to the URL from which you are accessing your linkding instance.
## Import fails with `502 Bad Gateway`
The default timeout for requests is 60 seconds, after which the application server will cancel the request and return the above error.

16
package-lock.json generated
View File

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

View File

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

View File

@@ -1,10 +1,10 @@
asgiref==3.5.2
beautifulsoup4==4.11.1
certifi==2022.6.15
certifi==2022.12.7
charset-normalizer==2.1.1
click==8.1.3
confusable-homoglyphs==3.2.0
Django==4.1
Django==4.1.2
django-generate-secret-key==1.0.2
django-registration==3.3
django-sass-processor==1.2.1
@@ -12,6 +12,7 @@ django-widget-tweaks==1.4.12
django4-background-tasks==1.2.7
djangorestframework==3.13.1
idna==3.3
psycopg2==2.9.5
python-dateutil==2.8.2
pytz==2022.2.1
requests==2.28.1

View File

@@ -1,11 +1,11 @@
asgiref==3.5.2
beautifulsoup4==4.11.1
certifi==2022.6.15
certifi==2022.12.7
charset-normalizer==2.1.1
click==8.1.3
confusable-homoglyphs==3.2.0
coverage==5.5
Django==4.1
Django==4.1.2
django-appconf==1.0.5
django-compressor==4.1
django-debug-toolbar==3.6.0
@@ -17,6 +17,7 @@ django4-background-tasks==1.2.7
djangorestframework==3.13.1
idna==3.3
libsass==0.21.0
psycopg2-binary==2.9.5
python-dateutil==2.8.2
pytz==2022.2.1
rcssmin==1.1.0

View File

@@ -11,7 +11,8 @@ export default {
sourcemap: true,
format: 'iife',
name: 'linkding',
file: 'build/bundle.js'
// Generate bundle in static folder to that it is picked up by Django static files finder
file: 'bookmarks/static/bundle.js'
},
plugins: [
svelte({

View File

@@ -79,16 +79,6 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
WSGI_APPLICATION = 'siteroot.wsgi.application'
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'data', 'db.sqlite3'),
}
}
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
@@ -138,7 +128,7 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'static')
# Turn off SASS compilation by default
SASS_PROCESSOR_ENABLED = False
# Location where generated CSS files are saved
SASS_PROCESSOR_ROOT = os.path.join(BASE_DIR, 'tmp', 'build', 'styles')
SASS_PROCESSOR_ROOT = os.path.join(BASE_DIR, 'bookmarks', 'static')
# Add SASS preprocessor finder to resolve generated CSS
STATICFILES_FINDERS = [
@@ -147,9 +137,8 @@ STATICFILES_FINDERS = [
'sass_processor.finders.CssFinder',
]
# Include SASS styles into static path, otherwise they can not be found by the SASS preprocessor
# Enable SASS processor to find custom folder for SCSS sources through static file finders
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'build'),
os.path.join(BASE_DIR, 'bookmarks', 'styles'),
]
@@ -199,3 +188,37 @@ if LD_ENABLE_AUTH_PROXY:
# Configure logout URL
if LD_AUTH_PROXY_LOGOUT_URL:
LOGOUT_REDIRECT_URL = LD_AUTH_PROXY_LOGOUT_URL
# CSRF trusted origins
trusted_origins = os.getenv('LD_CSRF_TRUSTED_ORIGINS', '')
if trusted_origins:
CSRF_TRUSTED_ORIGINS = trusted_origins.split(',')
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
LD_DB_ENGINE = os.getenv('LD_DB_ENGINE', 'sqlite')
LD_DB_HOST = os.getenv('LD_DB_HOST', 'localhost')
LD_DB_DATABASE = os.getenv('LD_DB_DATABASE', 'linkding')
LD_DB_USER = os.getenv('LD_DB_USER', 'linkding')
LD_DB_PASSWORD = os.getenv('LD_DB_PASSWORD', None)
LD_DB_PORT = os.getenv('LD_DB_PORT', None)
if LD_DB_ENGINE == 'postgres':
default_database = {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': LD_DB_DATABASE,
'USER': LD_DB_USER,
'PASSWORD': LD_DB_PASSWORD,
'HOST': LD_DB_HOST,
'PORT': LD_DB_PORT,
}
else:
default_database = {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'data', 'db.sqlite3'),
}
DATABASES = {
'default': default_database
}

View File

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

View File

@@ -11,6 +11,7 @@ stats = 127.0.0.1:9191
uid = www-data
gid = www-data
buffer-size = 8192
die-on-term = true
if-env = LD_CONTEXT_PATH
static-map = /%(_)static=static

View File

@@ -1 +1 @@
1.15.0
1.16.1