mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-14 22:19:32 +02:00
Compare commits
51 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fd3070c6f3 | ||
![]() |
bc374e90a2 | ||
![]() |
a94eb5f85a | ||
![]() |
d1819c6503 | ||
![]() |
353ba433f0 | ||
![]() |
3af4e07eb6 | ||
![]() |
e9061f373a | ||
![]() |
f87398742a | ||
![]() |
81dc19958c | ||
![]() |
5049ff14cf | ||
![]() |
f9ab3d1f44 | ||
![]() |
b89e150088 | ||
![]() |
d17801ba84 | ||
![]() |
7b52663383 | ||
![]() |
0c86587b5d | ||
![]() |
74134d3896 | ||
![]() |
89a9271c71 | ||
![]() |
794b6d8932 | ||
![]() |
6b4664117b | ||
![]() |
621b497dc6 | ||
![]() |
4bb05f811b | ||
![]() |
fb8e6b3b5f | ||
![]() |
814401be2e | ||
![]() |
4cb39fae99 | ||
![]() |
30da1880a5 | ||
![]() |
da99b8b034 | ||
![]() |
894625aa25 | ||
![]() |
62d7fb5f63 | ||
![]() |
fa2633147a | ||
![]() |
ddf97b0a3f | ||
![]() |
d3b4aa7602 | ||
![]() |
021d1cd673 | ||
![]() |
43d52642a6 | ||
![]() |
4f9170c48d | ||
![]() |
313a0ee99f | ||
![]() |
4e32bafe89 | ||
![]() |
035399442a | ||
![]() |
c2d8cde86b | ||
![]() |
13e0516961 | ||
![]() |
7b03ceab98 | ||
![]() |
fee979a371 | ||
![]() |
9eaae1fcf5 | ||
![]() |
3abdd92430 | ||
![]() |
b99d7bf1cc | ||
![]() |
f84e2d2210 | ||
![]() |
2fd7704816 | ||
![]() |
277c1c76e3 | ||
![]() |
2787dcb769 | ||
![]() |
1c3651e91d | ||
![]() |
53be77aade | ||
![]() |
7148bc62c3 |
25
.env.sample
25
.env.sample
@@ -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,26 @@ 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=
|
||||
# Any additional options to pass to the database (default: {})
|
||||
LD_DB_OPTIONS=
|
||||
|
41
.github/workflows/main.yaml
vendored
41
.github/workflows/main.yaml
vendored
@@ -3,22 +3,45 @@ name: linkding CI
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
name: Run Django Tests
|
||||
unit_tests:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 14
|
||||
- name: Install Python dependencies
|
||||
run: pip install -r requirements.txt
|
||||
node-version: 18
|
||||
- name: Install Node dependencies
|
||||
run: npm install
|
||||
- name: Setup Python environment
|
||||
run: pip install -r requirements.txt
|
||||
- name: Run tests
|
||||
run: python manage.py test
|
||||
run: python manage.py test bookmarks.tests
|
||||
e2e_tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Install Node dependencies
|
||||
run: npm install
|
||||
- name: Setup Python environment
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
python manage.py compilescss
|
||||
python manage.py collectstatic --ignore=*.scss
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.e2e
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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.
|
||||
|
104
CHANGELOG.md
104
CHANGELOG.md
@@ -1,5 +1,109 @@
|
||||
# Changelog
|
||||
|
||||
## v1.17.2 (18/02/2023)
|
||||
|
||||
### What's Changed
|
||||
* Escape texts in exported HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/429
|
||||
* Bump django from 4.1.2 to 4.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/427
|
||||
* Make health check in Dockerfile honor context path setting by @mrex in https://github.com/sissbruecker/linkding/pull/407
|
||||
* Disable autocapitalization for tag input form by @joshdick in https://github.com/sissbruecker/linkding/pull/395
|
||||
|
||||
### New Contributors
|
||||
* @mrex made their first contribution in https://github.com/sissbruecker/linkding/pull/407
|
||||
* @joshdick made their first contribution in https://github.com/sissbruecker/linkding/pull/395
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.1...v1.17.2
|
||||
|
||||
---
|
||||
|
||||
## v1.17.1 (22/01/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix favicon being cleared by web archive snapshot task by @sissbruecker in https://github.com/sissbruecker/linkding/pull/405
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.0...v1.17.1
|
||||
|
||||
---
|
||||
|
||||
## v1.17.0 (21/01/2023)
|
||||
|
||||
### What's Changed
|
||||
* Add Health Check endpoint by @mckennajones in https://github.com/sissbruecker/linkding/pull/392
|
||||
* Cache website metadata to avoid duplicate scraping by @sissbruecker in https://github.com/sissbruecker/linkding/pull/401
|
||||
* Prefill form if URL is already bookmarked by @sissbruecker in https://github.com/sissbruecker/linkding/pull/402
|
||||
* Add option for showing bookmark favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/390
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.1...v1.17.0
|
||||
|
||||
---
|
||||
|
||||
## v1.16.1 (20/01/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix bookmark website metadata not being updated when URL changes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/400
|
||||
* Bump django from 4.1 to 4.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/391
|
||||
* Bump certifi from 2022.6.15 to 2022.12.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/374
|
||||
* Bump minimatch from 3.0.4 to 3.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/366
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.0...v1.16.1
|
||||
|
||||
---
|
||||
|
||||
## v1.16.0 (12/01/2023)
|
||||
|
||||
### 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
|
||||
|
10
Dockerfile
10
Dockerfile
@@ -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 curl
|
||||
WORKDIR /etc/linkding
|
||||
# copy prod dependencies
|
||||
COPY --from=prod-deps /opt/venv /opt/venv
|
||||
@@ -51,4 +51,8 @@ ENV PATH /opt/venv/bin:$PATH
|
||||
RUN ["chmod", "g+w", "."]
|
||||
# Run bootstrap logic
|
||||
RUN ["chmod", "+x", "./bootstrap.sh"]
|
||||
|
||||
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
||||
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
||||
|
||||
CMD ["./bootstrap.sh"]
|
||||
|
63
README.md
63
README.md
@@ -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.
|
||||
@@ -133,6 +193,7 @@ This section lists community projects around using linkding, in alphabetical ord
|
||||
- [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)
|
||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
@@ -1,4 +1,3 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework import viewsets, mixins, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
@@ -7,8 +6,8 @@ from rest_framework.routers import DefaultRouter
|
||||
from bookmarks import queries
|
||||
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
|
||||
from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
from bookmarks.services.website_loader import load_website_metadata
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
|
||||
|
||||
class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
@@ -24,7 +23,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
# For list action, use query set that applies search and tag projections
|
||||
if self.action == 'list':
|
||||
query_string = self.request.GET.get('q')
|
||||
return queries.query_bookmarks(user, query_string)
|
||||
return queries.query_bookmarks(user, user.profile, query_string)
|
||||
|
||||
# For single entity actions use default query set without projections
|
||||
return Bookmark.objects.all().filter(owner=user)
|
||||
@@ -36,7 +35,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
def archived(self, request):
|
||||
user = request.user
|
||||
query_string = request.GET.get('q')
|
||||
query_set = queries.query_archived_bookmarks(user, query_string)
|
||||
query_set = queries.query_archived_bookmarks(user, user.profile, query_string)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
@@ -46,7 +45,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
def shared(self, request):
|
||||
filters = BookmarkFilters(request)
|
||||
user = User.objects.filter(username=filters.user).first()
|
||||
query_set = queries.query_shared_bookmarks(user, filters.query)
|
||||
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
@@ -68,15 +67,13 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
def check(self, request):
|
||||
url = request.GET.get('url')
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
existing_bookmark_data = None
|
||||
existing_bookmark_data = self.get_serializer(bookmark).data if bookmark else None
|
||||
|
||||
if bookmark is not None:
|
||||
existing_bookmark_data = {
|
||||
'id': bookmark.id,
|
||||
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
|
||||
}
|
||||
|
||||
metadata = load_website_metadata(url)
|
||||
# Either return metadata from existing bookmark, or scrape from URL
|
||||
if bookmark:
|
||||
metadata = WebsiteMetadata(url, bookmark.website_title, bookmark.website_description)
|
||||
else:
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
|
||||
return Response({
|
||||
'bookmark': existing_bookmark_data,
|
||||
|
@@ -119,7 +119,7 @@
|
||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||
<!-- autocomplete real input box -->
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder=" "
|
||||
class="form-input" type="text" autocomplete="off"
|
||||
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
||||
on:input={handleInput} on:keydown={handleKeyDown}
|
||||
on:focus={handleFocus} on:blur={handleBlur}>
|
||||
</div>
|
||||
|
0
bookmarks/e2e/__init__.py
Normal file
0
bookmarks/e2e/__init__.py
Normal file
21
bookmarks/e2e/helpers.py
Normal file
21
bookmarks/e2e/helpers.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.contrib.staticfiles.testing import LiveServerTestCase
|
||||
from playwright.sync_api import BrowserContext
|
||||
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
def setUp(self) -> None:
|
||||
self.client.force_login(self.get_or_create_test_user())
|
||||
self.cookie = self.client.cookies['sessionid']
|
||||
|
||||
def setup_browser(self, playwright) -> BrowserContext:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
context = browser.new_context()
|
||||
context.add_cookies([{
|
||||
'name': 'sessionid',
|
||||
'value': self.cookie.value,
|
||||
'domain': self.live_server_url.replace('http:', ''),
|
||||
'path': '/'
|
||||
}])
|
||||
return context
|
51
bookmarks/e2e/test_bookmark_form.py
Normal file
51
bookmarks/e2e/test_bookmark_form.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_create_should_check_for_existing_bookmark(self):
|
||||
existing_bookmark = self.setup_bookmark(title='Existing title',
|
||||
description='Existing description',
|
||||
tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2')],
|
||||
website_title='Existing website title',
|
||||
website_description='Existing website description',
|
||||
unread=True)
|
||||
tag_names = ' '.join(existing_bookmark.tag_names)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse('bookmarks:new'))
|
||||
|
||||
# Enter bookmarked URL
|
||||
page.get_by_label('URL').fill(existing_bookmark.url)
|
||||
# Already bookmarked hint should be visible
|
||||
page.get_by_text('This URL is already bookmarked.').wait_for(timeout=2000)
|
||||
# Form should be pre-filled with data from existing bookmark
|
||||
self.assertEqual(existing_bookmark.title, page.get_by_label('Title').input_value())
|
||||
self.assertEqual(existing_bookmark.description, page.get_by_label('Description').input_value())
|
||||
self.assertEqual(existing_bookmark.website_title, page.get_by_label('Title').get_attribute('placeholder'))
|
||||
self.assertEqual(existing_bookmark.website_description,
|
||||
page.get_by_label('Description').get_attribute('placeholder'))
|
||||
self.assertEqual(tag_names, page.get_by_label('Tags').input_value())
|
||||
self.assertTrue(tag_names, page.get_by_label('Mark as unread').is_checked())
|
||||
|
||||
# Enter non-bookmarked URL
|
||||
page.get_by_label('URL').fill('https://example.com/unknown')
|
||||
# Already bookmarked hint should be hidden
|
||||
page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden', timeout=2000)
|
||||
|
||||
browser.close()
|
||||
|
||||
def test_edit_should_not_check_for_existing_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden')
|
30
bookmarks/e2e/test_global_shortcuts.py
Normal file
30
bookmarks/e2e/test_global_shortcuts.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_focus_search(self):
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse('bookmarks:index'))
|
||||
|
||||
page.press('body', 's')
|
||||
|
||||
expect(page.get_by_placeholder('Search for words or #tags')).to_be_focused()
|
||||
|
||||
browser.close()
|
||||
|
||||
def test_add_bookmark(self):
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse('bookmarks:index'))
|
||||
|
||||
page.press('body', 'n')
|
||||
|
||||
expect(page).to_have_url(self.live_server_url + reverse('bookmarks:new'))
|
||||
|
||||
browser.close()
|
@@ -18,7 +18,7 @@ class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||
query_string = request.GET.get('q')
|
||||
query_set = queries.query_bookmarks(feed_token.user, query_string)
|
||||
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, query_string)
|
||||
return FeedContext(feed_token, query_set)
|
||||
|
||||
def item_title(self, item: Bookmark):
|
||||
|
18
bookmarks/migrations/0018_bookmark_favicon_file.py
Normal file
18
bookmarks/migrations/0018_bookmark_favicon_file.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1 on 2023-01-07 23:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0017_userprofile_enable_sharing'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='favicon_file',
|
||||
field=models.CharField(blank=True, max_length=512),
|
||||
),
|
||||
]
|
18
bookmarks/migrations/0019_userprofile_enable_favicons.py
Normal file
18
bookmarks/migrations/0019_userprofile_enable_favicons.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1 on 2023-01-09 21:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0018_bookmark_favicon_file'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='enable_favicons',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
18
bookmarks/migrations/0020_userprofile_tag_search.py
Normal file
18
bookmarks/migrations/0020_userprofile_tag_search.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-10 01:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0019_userprofile_enable_favicons'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='tag_search',
|
||||
field=models.CharField(choices=[('strict', 'Strict'), ('lax', 'Lax')], default='strict', max_length=10),
|
||||
),
|
||||
]
|
18
bookmarks/migrations/0021_userprofile_display_url.py
Normal file
18
bookmarks/migrations/0021_userprofile_display_url.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-05-18 07:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0020_userprofile_tag_search'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='display_url',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@@ -53,6 +53,7 @@ class Bookmark(models.Model):
|
||||
website_title = models.CharField(max_length=512, blank=True, null=True)
|
||||
website_description = models.TextField(blank=True, null=True)
|
||||
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
||||
favicon_file = models.CharField(max_length=512, blank=True)
|
||||
unread = models.BooleanField(default=False)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
shared = models.BooleanField(default=False)
|
||||
@@ -152,6 +153,12 @@ class UserProfile(models.Model):
|
||||
(WEB_ARCHIVE_INTEGRATION_DISABLED, 'Disabled'),
|
||||
(WEB_ARCHIVE_INTEGRATION_ENABLED, 'Enabled'),
|
||||
]
|
||||
TAG_SEARCH_STRICT = 'strict'
|
||||
TAG_SEARCH_LAX = 'lax'
|
||||
TAG_SEARCH_CHOICES = [
|
||||
(TAG_SEARCH_STRICT, 'Strict'),
|
||||
(TAG_SEARCH_LAX, 'Lax'),
|
||||
]
|
||||
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
|
||||
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
|
||||
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
|
||||
@@ -160,13 +167,18 @@ class UserProfile(models.Model):
|
||||
default=BOOKMARK_LINK_TARGET_BLANK)
|
||||
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
|
||||
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
|
||||
tag_search = models.CharField(max_length=10, choices=TAG_SEARCH_CHOICES, blank=False,
|
||||
default=TAG_SEARCH_STRICT)
|
||||
enable_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_favicons = models.BooleanField(default=False, null=False)
|
||||
display_url = models.BooleanField(default=False, null=False)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing']
|
||||
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search',
|
||||
'enable_sharing', 'enable_favicons', 'display_url']
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
|
@@ -1,29 +1,29 @@
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.db.models import Q, QuerySet, Exists, OuterRef
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
def query_bookmarks(user: User, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, query_string) \
|
||||
def query_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, query_string) \
|
||||
.filter(is_archived=False)
|
||||
|
||||
|
||||
def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, query_string) \
|
||||
def query_archived_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, query_string) \
|
||||
.filter(is_archived=True)
|
||||
|
||||
|
||||
def query_shared_bookmarks(user: Optional[User], query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, query_string) \
|
||||
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, query_string) \
|
||||
.filter(shared=True) \
|
||||
.filter(owner__profile__enable_sharing=True)
|
||||
|
||||
|
||||
def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
|
||||
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
||||
query_set = Bookmark.objects
|
||||
|
||||
# Filter for user
|
||||
@@ -35,13 +35,16 @@ def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
|
||||
|
||||
# Filter for search terms and tags
|
||||
for term in query['search_terms']:
|
||||
query_set = query_set.filter(
|
||||
Q(title__contains=term)
|
||||
| Q(description__contains=term)
|
||||
| Q(website_title__contains=term)
|
||||
| Q(website_description__contains=term)
|
||||
| Q(url__contains=term)
|
||||
)
|
||||
conditions = Q(title__icontains=term) \
|
||||
| Q(description__icontains=term) \
|
||||
| Q(website_title__icontains=term) \
|
||||
| Q(website_description__icontains=term) \
|
||||
| Q(url__icontains=term)
|
||||
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
conditions = conditions | Exists(Bookmark.objects.filter(id=OuterRef('id'), tags__name__iexact=term))
|
||||
|
||||
query_set = query_set.filter(conditions)
|
||||
|
||||
for tag_name in query['tag_names']:
|
||||
query_set = query_set.filter(
|
||||
@@ -65,32 +68,32 @@ def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
|
||||
return query_set
|
||||
|
||||
|
||||
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_bookmarks(user, query_string)
|
||||
def query_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_bookmarks(user, profile, query_string)
|
||||
|
||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||
|
||||
return query_set.distinct()
|
||||
|
||||
|
||||
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_archived_bookmarks(user, query_string)
|
||||
def query_archived_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_archived_bookmarks(user, profile, query_string)
|
||||
|
||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||
|
||||
return query_set.distinct()
|
||||
|
||||
|
||||
def query_shared_bookmark_tags(user: Optional[User], query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_shared_bookmarks(user, query_string)
|
||||
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_shared_bookmarks(user, profile, query_string)
|
||||
|
||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||
|
||||
return query_set.distinct()
|
||||
|
||||
|
||||
def query_shared_bookmark_users(query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_shared_bookmarks(None, query_string)
|
||||
def query_shared_bookmark_users(profile: UserProfile, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_shared_bookmarks(None, profile, query_string)
|
||||
|
||||
query_set = User.objects.filter(bookmark__in=bookmarks_query)
|
||||
|
||||
|
@@ -30,6 +30,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
bookmark.save()
|
||||
# Create snapshot on web archive
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
||||
# Load favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
|
||||
return bookmark
|
||||
|
||||
@@ -43,11 +45,15 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
# Update dates
|
||||
bookmark.date_modified = timezone.now()
|
||||
bookmark.save()
|
||||
# Update favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
|
||||
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)
|
||||
bookmark.save()
|
||||
|
||||
return bookmark
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from typing import List
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
@@ -28,8 +29,8 @@ def append_list_start(doc: BookmarkDocument):
|
||||
|
||||
def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
||||
url = bookmark.url
|
||||
title = bookmark.resolved_title
|
||||
desc = bookmark.resolved_description
|
||||
title = html.escape(bookmark.resolved_title or '')
|
||||
desc = html.escape(bookmark.resolved_description or '')
|
||||
tags = ','.join(bookmark.tag_names)
|
||||
toread = '1' if bookmark.unread else '0'
|
||||
added = int(bookmark.date_added.timestamp())
|
||||
|
57
bookmarks/services/favicon_loader.py
Normal file
57
bookmarks/services/favicon_loader.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import os.path
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
max_file_age = 60 * 60 * 24 # 1 day
|
||||
|
||||
|
||||
def _ensure_favicon_folder():
|
||||
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _url_to_filename(url: str) -> str:
|
||||
name = re.sub(r'\W+', '_', url)
|
||||
return f'{name}.png'
|
||||
|
||||
|
||||
def _get_base_url(url: str) -> str:
|
||||
parsed_uri = urlparse(url)
|
||||
return f'{parsed_uri.scheme}://{parsed_uri.hostname}'
|
||||
|
||||
|
||||
def _get_favicon_path(favicon_file: str) -> Path:
|
||||
return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file))
|
||||
|
||||
|
||||
def _is_stale(path: Path) -> bool:
|
||||
stat = path.stat()
|
||||
file_age = time.time() - stat.st_mtime
|
||||
return file_age >= max_file_age
|
||||
|
||||
|
||||
def load_favicon(url: str) -> str:
|
||||
# Get base URL so that we can reuse favicons for multiple bookmarks with the same host
|
||||
base_url = _get_base_url(url)
|
||||
favicon_name = _url_to_filename(base_url)
|
||||
favicon_path = _get_favicon_path(favicon_name)
|
||||
|
||||
# Load icon if it doesn't exist yet or has become stale
|
||||
if not favicon_path.exists() or _is_stale(favicon_path):
|
||||
# Create favicon folder if not exists
|
||||
_ensure_favicon_folder()
|
||||
# Load favicon from provider, save to file
|
||||
favicon_url = settings.LD_FAVICON_PROVIDER.format(url=base_url)
|
||||
response = requests.get(favicon_url, stream=True)
|
||||
|
||||
with open(favicon_path, 'wb') as file:
|
||||
shutil.copyfileobj(response.raw, file)
|
||||
|
||||
del response
|
||||
|
||||
return favicon_name
|
@@ -74,6 +74,8 @@ def import_netscape_html(html: str, user: User):
|
||||
|
||||
# Create snapshots for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
# Load favicons for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f'Import duration: {end - import_start}')
|
||||
|
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
import waybackpy
|
||||
from background_task import background
|
||||
from background_task.models import Task
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
@@ -9,6 +10,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord
|
||||
|
||||
import bookmarks.services.wayback
|
||||
from bookmarks.models import Bookmark, UserProfile
|
||||
from bookmarks.services import favicon_loader
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,7 +37,7 @@ def _load_newest_snapshot(bookmark: Bookmark):
|
||||
|
||||
if existing_snapshot:
|
||||
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
|
||||
bookmark.save()
|
||||
bookmark.save(update_fields=['web_archive_snapshot_url'])
|
||||
logger.info(f'Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}')
|
||||
|
||||
except NoCDXRecordFound:
|
||||
@@ -49,7 +51,7 @@ def _create_snapshot(bookmark: Bookmark):
|
||||
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT, max_tries=1)
|
||||
archive.save()
|
||||
bookmark.web_archive_snapshot_url = archive.archive_url
|
||||
bookmark.save()
|
||||
bookmark.save(update_fields=['web_archive_snapshot_url'])
|
||||
logger.info(f'Successfully created new snapshot for bookmark:. url={bookmark.url}')
|
||||
|
||||
|
||||
@@ -72,7 +74,8 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
||||
logger.error(
|
||||
f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}')
|
||||
except WaybackError as error:
|
||||
logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}', exc_info=error)
|
||||
logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}',
|
||||
exc_info=error)
|
||||
|
||||
# Load the newest snapshot as fallback
|
||||
_load_newest_snapshot(bookmark)
|
||||
@@ -105,3 +108,67 @@ def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
|
||||
# new ones when processing bookmarks in bulk
|
||||
_load_web_archive_snapshot_task(bookmark.id)
|
||||
|
||||
|
||||
def is_favicon_feature_active(user: User) -> bool:
|
||||
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
|
||||
return background_tasks_enabled and user.profile.enable_favicons
|
||||
|
||||
|
||||
def load_favicon(user: User, bookmark: Bookmark):
|
||||
if is_favicon_feature_active(user):
|
||||
_load_favicon_task(bookmark.id)
|
||||
|
||||
|
||||
@background()
|
||||
def _load_favicon_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
logger.info(f'Load favicon for bookmark. url={bookmark.url}')
|
||||
|
||||
new_favicon = favicon_loader.load_favicon(bookmark.url)
|
||||
|
||||
if new_favicon != bookmark.favicon_file:
|
||||
bookmark.favicon_file = new_favicon
|
||||
bookmark.save(update_fields=['favicon_file'])
|
||||
logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon}')
|
||||
|
||||
|
||||
def schedule_bookmarks_without_favicons(user: User):
|
||||
if is_favicon_feature_active(user):
|
||||
_schedule_bookmarks_without_favicons_task(user.id)
|
||||
|
||||
|
||||
@background()
|
||||
def _schedule_bookmarks_without_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(favicon_file__exact='', owner=user)
|
||||
tasks = []
|
||||
|
||||
for bookmark in bookmarks:
|
||||
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
|
||||
tasks.append(task)
|
||||
|
||||
Task.objects.bulk_create(tasks)
|
||||
|
||||
|
||||
def schedule_refresh_favicons(user: User):
|
||||
if is_favicon_feature_active(user) and settings.LD_ENABLE_REFRESH_FAVICONS:
|
||||
_schedule_refresh_favicons_task(user.id)
|
||||
|
||||
|
||||
@background()
|
||||
def _schedule_refresh_favicons_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(owner=user)
|
||||
tasks = []
|
||||
|
||||
for bookmark in bookmarks:
|
||||
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
|
||||
tasks.append(task)
|
||||
|
||||
Task.objects.bulk_create(tasks)
|
||||
|
@@ -1,8 +1,13 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from charset_normalizer import from_bytes
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -19,29 +24,68 @@ class WebsiteMetadata:
|
||||
}
|
||||
|
||||
|
||||
# Caching metadata avoids scraping again when saving bookmarks, in case the
|
||||
# metadata was already scraped to show preview values in the bookmark form
|
||||
@lru_cache(maxsize=10)
|
||||
def load_website_metadata(url: str):
|
||||
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())
|
||||
|
||||
|
||||
|
@@ -102,3 +102,12 @@ a:visited:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
// Increase input font size on small viewports to prevent zooming on focus the input
|
||||
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
||||
// viewport size
|
||||
@media screen and (max-width: 430px) {
|
||||
.form-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
@@ -58,6 +58,16 @@ ul.bookmark-list {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.url-display {
|
||||
color: $secondary-link-color;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $gray-color-dark;
|
||||
|
||||
|
@@ -21,6 +21,8 @@ $link-color: $primary-color !default;
|
||||
$link-color-dark: darken($link-color, 5%) !default;
|
||||
$link-color-light: $link-color !default;
|
||||
|
||||
$secondary-link-color: rgba(168, 177, 255, 0.73);
|
||||
|
||||
$alternative-color: #59bdb9;
|
||||
$alternative-color-dark: #73f1eb;
|
||||
|
||||
|
@@ -2,3 +2,5 @@ $html-font-size: 18px !default;
|
||||
|
||||
$alternative-color: #05a6a3;
|
||||
$alternative-color-dark: darken($alternative-color, 5%);
|
||||
|
||||
$secondary-link-color: rgba(87, 85, 217, 0.64);
|
@@ -1,3 +1,4 @@
|
||||
{% load static %}
|
||||
{% load shared %}
|
||||
{% load pagination %}
|
||||
{% htmlmin %}
|
||||
@@ -11,14 +12,25 @@
|
||||
<div class="title">
|
||||
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
|
||||
class="{% if bookmark.unread %}text-italic{% endif %}">
|
||||
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
|
||||
<img src="{% static bookmark.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
{{ bookmark.resolved_title }}
|
||||
</a>
|
||||
</div>
|
||||
{% if request.user.profile.display_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
|
||||
class="url-display text-sm">
|
||||
{{ bookmark.url }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="description truncate">
|
||||
{% if bookmark.tag_names %}
|
||||
<span>
|
||||
{% for tag_name in bookmark.tag_names %}
|
||||
<a href="?{% append_to_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
@@ -15,13 +15,13 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-input-hint bookmark-exists">
|
||||
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark
|
||||
by saving this form.
|
||||
This URL is already bookmarked.
|
||||
The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
||||
exist it will be
|
||||
@@ -128,6 +128,9 @@
|
||||
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 tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
||||
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
|
||||
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
|
||||
const websiteTitleInput = document.getElementById('{{ form.website_title.id_for_label }}');
|
||||
const websiteDescriptionInput = document.getElementById('{{ form.website_description.id_for_label }}');
|
||||
const editedBookmarkId = {{ bookmark_id }};
|
||||
@@ -145,6 +148,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateInput(input, value) {
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
function updateCheckbox(input, value) {
|
||||
input.checked = value;
|
||||
}
|
||||
|
||||
function checkUrl() {
|
||||
toggleLoadingIcon(titleInput, true);
|
||||
toggleLoadingIcon(descriptionInput, true);
|
||||
@@ -162,13 +173,17 @@
|
||||
toggleLoadingIcon(titleInput, false);
|
||||
toggleLoadingIcon(descriptionInput, false);
|
||||
|
||||
// Display hint if URL is already bookmarked
|
||||
// Prefill form and display hint if URL is already bookmarked
|
||||
const existingBookmark = data.bookmark;
|
||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
||||
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a');
|
||||
|
||||
if (data.bookmark && data.bookmark.id !== editedBookmarkId) {
|
||||
if (existingBookmark && !editedBookmarkId) {
|
||||
bookmarkExistsHint.style['display'] = 'block';
|
||||
editExistingBookmarkLink.href = data.bookmark.edit_url;
|
||||
updateInput(titleInput, existingBookmark.title);
|
||||
updateInput(descriptionInput, existingBookmark.description);
|
||||
updateInput(tagsInput, existingBookmark.tag_names.join(" "));
|
||||
updateCheckbox(unreadCheckbox, existingBookmark.unread);
|
||||
updateCheckbox(sharedCheckbox, existingBookmark.shared);
|
||||
} else {
|
||||
bookmarkExistsHint.style['display'] = 'none';
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@
|
||||
{% if has_selected_tags %}
|
||||
<p class="selected-tags">
|
||||
{% for tag in selected_tags %}
|
||||
<a href="?{% remove_from_query_param q=tag.name|hash_tag %}"
|
||||
<a href="?{% remove_tag_from_query tag.name %}"
|
||||
class="text-bold mr-2">
|
||||
<span>-{{ tag.name }}</span>
|
||||
</a>
|
||||
@@ -17,14 +17,14 @@
|
||||
{% for tag in group.tags %}
|
||||
{# Highlight first char of first tag in group #}
|
||||
{% if forloop.counter == 1 %}
|
||||
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
|
||||
<a href="?{% add_tag_to_query tag.name %}"
|
||||
class="mr-2" data-is-tag-item>
|
||||
<span
|
||||
class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
{# Render remaining tags normally #}
|
||||
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
|
||||
<a href="?{% add_tag_to_query tag.name %}"
|
||||
class="mr-2" data-is-tag-item>
|
||||
<span>{{ tag.name }}</span>
|
||||
</a>
|
||||
|
@@ -29,6 +29,15 @@
|
||||
be hidden.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
|
||||
{{ form.display_url }}
|
||||
<i class="form-icon"></i> Show bookmark URL
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
When enabled, this setting displays the bookmark URL below the title.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
|
||||
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}
|
||||
@@ -36,6 +45,39 @@
|
||||
Whether to open bookmarks a new page or in the same page.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
||||
{{ form.tag_search|add_class:"form-select col-2 col-sm-12" }}
|
||||
<div class="form-input-hint">
|
||||
In strict mode, tags must be prefixed with a hash character (#).
|
||||
In lax mode, tags can also be searched without the hash character.
|
||||
Note that tags without the hash character are indistinguishable from search terms, which means the search result will also include bookmarks where a search term matches otherwise.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
|
||||
{{ form.enable_favicons }}
|
||||
<i class="form-icon"></i> Enable Favicons
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
|
||||
By default, this feature uses a <b>Google service</b> to download favicons.
|
||||
If you don't want to use this service, check the <a
|
||||
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md" target="_blank">options
|
||||
documentation</a> on how to configure a custom favicon provider.
|
||||
Icons are downloaded in the background, and it may take a while for them to show up.
|
||||
</div>
|
||||
{% if request.user.profile.enable_favicons and enable_refresh_favicons %}
|
||||
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||
{% endif %}
|
||||
{% if refresh_favicons_success_message %}
|
||||
<div class="has-success">
|
||||
<p class="form-input-hint">
|
||||
{{ refresh_favicons_success_message }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||
integration</label>
|
||||
@@ -61,7 +103,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="submit" value="Save" class="btn btn-primary mt-2">
|
||||
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
|
||||
{% if update_profile_success_message %}
|
||||
<div class="has-success">
|
||||
<p class="form-input-hint">
|
||||
{{ update_profile_success_message }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
@@ -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 
 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>
|
||||
|
@@ -3,6 +3,7 @@ import re
|
||||
from django import template
|
||||
|
||||
from bookmarks import utils
|
||||
from bookmarks.models import UserProfile
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -19,36 +20,39 @@ def update_query_string(context, **kwargs):
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def append_to_query_param(context, **kwargs):
|
||||
query = context.request.GET.copy()
|
||||
def add_tag_to_query(context, tag_name: str):
|
||||
params = context.request.GET.copy()
|
||||
|
||||
# Append to or create query param
|
||||
for key in kwargs:
|
||||
if query.__contains__(key):
|
||||
value = query.__getitem__(key) + ' '
|
||||
else:
|
||||
value = ''
|
||||
value = value + kwargs[key]
|
||||
query.__setitem__(key, value)
|
||||
# Append to or create query string
|
||||
if params.__contains__('q'):
|
||||
query_string = params.__getitem__('q') + ' '
|
||||
else:
|
||||
query_string = ''
|
||||
query_string = query_string + '#' + tag_name
|
||||
params.__setitem__('q', query_string)
|
||||
|
||||
return query.urlencode()
|
||||
return params.urlencode()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def remove_from_query_param(context, **kwargs):
|
||||
query = context.request.GET.copy()
|
||||
def remove_tag_from_query(context, tag_name: str):
|
||||
params = context.request.GET.copy()
|
||||
if params.__contains__('q'):
|
||||
# Split query string into parts
|
||||
query_string = params.__getitem__('q')
|
||||
query_parts = query_string.split()
|
||||
# Remove tag with hash
|
||||
tag_name_with_hash = '#' + tag_name
|
||||
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name_with_hash)]
|
||||
# When using lax tag search, also remove tag without hash
|
||||
profile = context.request.user.profile
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name)]
|
||||
# Rebuild query string
|
||||
query_string = ' '.join(query_parts)
|
||||
params.__setitem__('q', query_string)
|
||||
|
||||
# Remove item from query param
|
||||
for key in kwargs:
|
||||
if query.__contains__(key):
|
||||
value = query.__getitem__(key)
|
||||
parts = value.split()
|
||||
part_to_remove = kwargs[key]
|
||||
updated_parts = [part for part in parts if str.lower(part) != str.lower(part_to_remove)]
|
||||
updated_value = ' '.join(updated_parts)
|
||||
query.__setitem__(key, updated_value)
|
||||
|
||||
return query.urlencode()
|
||||
return params.urlencode()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
|
@@ -33,6 +33,7 @@ class BookmarkFactoryMixin:
|
||||
website_title: str = '',
|
||||
website_description: str = '',
|
||||
web_archive_snapshot_url: str = '',
|
||||
favicon_file: str = '',
|
||||
):
|
||||
if not title:
|
||||
title = get_random_string(length=32)
|
||||
@@ -56,6 +57,7 @@ class BookmarkFactoryMixin:
|
||||
unread=unread,
|
||||
shared=shared,
|
||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||
favicon_file=favicon_file,
|
||||
)
|
||||
bookmark.save()
|
||||
for tag in tags:
|
||||
|
29
bookmarks/tests/test_app_options.py
Normal file
29
bookmarks/tests/test_app_options.py
Normal 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'])
|
@@ -158,6 +158,37 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(self):
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
self.setup_bookmark(title=tags[0].name, tags=tags, is_archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
|
||||
self.assertSelectedTags(response, [tags[1]])
|
||||
|
||||
def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):
|
||||
self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
self.user.profile.save()
|
||||
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
self.setup_bookmark(tags=tags, is_archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=True),
|
||||
|
@@ -87,7 +87,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag_string = build_tag_string(bookmark.tag_names, ' ')
|
||||
self.assertInHTML(f'''
|
||||
<input type="text" name="tag_string" value="{tag_string}"
|
||||
autocomplete="off" class="form-input" id="id_tag_string">
|
||||
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string">
|
||||
''', html)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
|
@@ -155,7 +155,38 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
]
|
||||
self.setup_bookmark(tags=tags)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name}')
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name.upper()}')
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(self):
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
self.setup_bookmark(title=tags[0].name, tags=tags)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
|
||||
self.assertSelectedTags(response, [tags[1]])
|
||||
|
||||
def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):
|
||||
self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
self.user.profile.save()
|
||||
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
self.setup_bookmark(tags=tags)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import urllib.parse
|
||||
from collections import OrderedDict
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
@@ -6,6 +8,8 @@ from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
||||
|
||||
|
||||
@@ -353,6 +357,66 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
|
||||
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
|
||||
url = reverse('bookmarks:bookmark-check')
|
||||
check_url = urllib.parse.quote_plus('https://example.com')
|
||||
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||
bookmark_data = response.data['bookmark']
|
||||
|
||||
self.assertIsNone(bookmark_data)
|
||||
|
||||
def test_check_returns_scraped_metadata_if_url_is_not_bookmarked(self):
|
||||
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
||||
expected_metadata = WebsiteMetadata(
|
||||
'https://example.com',
|
||||
'Scraped metadata',
|
||||
'Scraped description'
|
||||
)
|
||||
mock_load_website_metadata.return_value = expected_metadata
|
||||
|
||||
url = reverse('bookmarks:bookmark-check')
|
||||
check_url = urllib.parse.quote_plus('https://example.com')
|
||||
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||
metadata = response.data['metadata']
|
||||
|
||||
self.assertIsNotNone(metadata)
|
||||
self.assertIsNotNone(expected_metadata.url, metadata['url'])
|
||||
self.assertIsNotNone(expected_metadata.title, metadata['title'])
|
||||
self.assertIsNotNone(expected_metadata.description, metadata['description'])
|
||||
|
||||
def test_check_returns_bookmark_if_url_is_bookmarked(self):
|
||||
bookmark = self.setup_bookmark(url='https://example.com',
|
||||
title='Example title',
|
||||
description='Example description')
|
||||
|
||||
url = reverse('bookmarks:bookmark-check')
|
||||
check_url = urllib.parse.quote_plus('https://example.com')
|
||||
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||
bookmark_data = response.data['bookmark']
|
||||
|
||||
self.assertIsNotNone(bookmark_data)
|
||||
self.assertEqual(bookmark.id, bookmark_data['id'])
|
||||
self.assertEqual(bookmark.url, bookmark_data['url'])
|
||||
self.assertEqual(bookmark.title, bookmark_data['title'])
|
||||
self.assertEqual(bookmark.description, bookmark_data['description'])
|
||||
|
||||
def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
|
||||
bookmark = self.setup_bookmark(url='https://example.com',
|
||||
website_title='Existing title',
|
||||
website_description='Existing description')
|
||||
|
||||
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
||||
url = reverse('bookmarks:bookmark-check')
|
||||
check_url = urllib.parse.quote_plus('https://example.com')
|
||||
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||
metadata = response.data['metadata']
|
||||
|
||||
mock_load_website_metadata.assert_not_called()
|
||||
self.assertIsNotNone(metadata)
|
||||
self.assertIsNotNone(bookmark.url, metadata['url'])
|
||||
self.assertIsNotNone(bookmark.website_title, metadata['title'])
|
||||
self.assertIsNotNone(bookmark.website_description, metadata['description'])
|
||||
|
||||
def test_can_only_access_own_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
inaccessible_bookmark = self.setup_bookmark(user=other_user)
|
||||
@@ -396,3 +460,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_shared_bookmark.id])
|
||||
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
url = reverse('bookmarks:bookmark-check')
|
||||
check_url = urllib.parse.quote_plus(inaccessible_bookmark.url)
|
||||
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||
self.assertIsNone(response.data['bookmark'])
|
||||
|
@@ -79,6 +79,36 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
</span>
|
||||
''', html, count=count)
|
||||
|
||||
def assertFaviconVisible(self, html: str, bookmark: Bookmark):
|
||||
self.assertFaviconCount(html, bookmark, 1)
|
||||
|
||||
def assertFaviconHidden(self, html: str, bookmark: Bookmark):
|
||||
self.assertFaviconCount(html, bookmark, 0)
|
||||
|
||||
def assertFaviconCount(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(f'''
|
||||
<img src="/static/{bookmark.favicon_file}" alt="">
|
||||
''', html, count=count)
|
||||
|
||||
def assertBookmarkURLCount(self, html: str, bookmark: Bookmark, link_target: str = '_blank', count=0):
|
||||
self.assertInHTML(f'''
|
||||
<div class="url-path truncate">
|
||||
<a href="{ bookmark.url }" target="{ link_target }" rel="noopener"
|
||||
class="url-display text-sm">
|
||||
{ bookmark.url }
|
||||
</a>
|
||||
</div>
|
||||
''', html, count)
|
||||
|
||||
def assertBookmarkURLVisible(self, html: str, bookmark: Bookmark):
|
||||
self.assertBookmarkURLCount(html, bookmark, count=1)
|
||||
|
||||
|
||||
def assertBookmarkURLHidden(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
|
||||
self.assertBookmarkURLCount(html, bookmark, count=0)
|
||||
|
||||
|
||||
|
||||
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
|
||||
rf = RequestFactory()
|
||||
request = rf.get(url)
|
||||
@@ -211,3 +241,64 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
|
||||
</span>
|
||||
''', html)
|
||||
|
||||
def test_favicon_should_be_visible_when_favicons_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_favicons = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertFaviconVisible(html, bookmark)
|
||||
|
||||
def test_favicon_should_be_hidden_when_there_is_no_icon(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_favicons = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(favicon_file='')
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertFaviconHidden(html, bookmark)
|
||||
|
||||
def test_favicon_should_be_hidden_when_favicons_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_favicons = False
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertFaviconHidden(html, bookmark)
|
||||
|
||||
def test_bookmark_url_should_be_hidden_by_default(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertBookmarkURLHidden(html,bookmark)
|
||||
|
||||
def test_show_bookmark_url_when_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.display_url = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertBookmarkURLVisible(html,bookmark)
|
||||
|
||||
def test_hide_bookmark_url_when_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.display_url = False
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertBookmarkURLHidden(html,bookmark)
|
||||
|
||||
|
||||
|
@@ -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',
|
||||
@@ -45,6 +67,13 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False)
|
||||
|
||||
def test_create_should_load_favicon(self):
|
||||
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
|
||||
bookmark_data = Bookmark(url='https://example.com')
|
||||
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
|
||||
|
||||
mock_load_favicon.assert_called_once_with(self.user, bookmark)
|
||||
|
||||
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
|
||||
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -63,11 +92,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:
|
||||
@@ -77,6 +116,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
mock_load_website_metadata.assert_not_called()
|
||||
|
||||
def test_update_should_update_favicon(self):
|
||||
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.title = 'updated title'
|
||||
update_bookmark(bookmark, 'tag1,tag2', self.user)
|
||||
|
||||
mock_load_favicon.assert_called_once_with(self.user, bookmark)
|
||||
|
||||
def test_archive_bookmark(self):
|
||||
bookmark = Bookmark(
|
||||
url='https://example.com',
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import patch
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import waybackpy
|
||||
from background_task.models import Task
|
||||
@@ -8,21 +9,21 @@ from django.contrib.auth.models import User
|
||||
from django.test import TestCase, override_settings
|
||||
from waybackpy.exceptions import WaybackError
|
||||
|
||||
import bookmarks.services.favicon_loader
|
||||
import bookmarks.services.wayback
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||
|
||||
|
||||
class MockWaybackMachineSaveAPI:
|
||||
def __init__(self, archive_url: str = 'https://example.com/created_snapshot', fail_on_save: bool = False):
|
||||
self.archive_url = archive_url
|
||||
self.fail_on_save = fail_on_save
|
||||
def create_wayback_machine_save_api_mock(archive_url: str = 'https://example.com/created_snapshot',
|
||||
fail_on_save: bool = False):
|
||||
mock_api = mock.Mock(archive_url=archive_url)
|
||||
|
||||
def save(self):
|
||||
if self.fail_on_save:
|
||||
raise WaybackError
|
||||
return self
|
||||
if fail_on_save:
|
||||
mock_api.save.side_effect = WaybackError
|
||||
|
||||
return mock_api
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -31,21 +32,18 @@ class MockCdxSnapshot:
|
||||
datetime_timestamp: datetime.datetime
|
||||
|
||||
|
||||
class MockWaybackMachineCDXServerAPI:
|
||||
def __init__(self,
|
||||
archive_url: str = 'https://example.com/newest_snapshot',
|
||||
has_no_snapshot=False,
|
||||
fail_loading_snapshot=False):
|
||||
self.archive_url = archive_url
|
||||
self.has_no_snapshot = has_no_snapshot
|
||||
self.fail_loading_snapshot = fail_loading_snapshot
|
||||
def create_cdx_server_api_mock(archive_url: str | None = 'https://example.com/newest_snapshot',
|
||||
fail_loading_snapshot=False):
|
||||
mock_api = mock.Mock()
|
||||
|
||||
def newest(self):
|
||||
if self.has_no_snapshot:
|
||||
return None
|
||||
if self.fail_loading_snapshot:
|
||||
raise WaybackError
|
||||
return MockCdxSnapshot(self.archive_url, datetime.datetime.now())
|
||||
if fail_loading_snapshot:
|
||||
mock_api.newest.side_effect = WaybackError
|
||||
elif archive_url:
|
||||
mock_api.newest.return_value = MockCdxSnapshot(archive_url, datetime.datetime.now())
|
||||
else:
|
||||
mock_api.newest.return_value = None
|
||||
|
||||
return mock_api
|
||||
|
||||
|
||||
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
@@ -53,49 +51,55 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self):
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||
user.profile.enable_favicons = True
|
||||
user.profile.save()
|
||||
|
||||
@disable_logging
|
||||
def run_pending_task(self, task_function):
|
||||
def run_pending_task(self, task_function: Any):
|
||||
func = getattr(task_function, 'task_function', None)
|
||||
task = Task.objects.all()[0]
|
||||
self.assertEqual(task_function.name, task.task_name)
|
||||
args, kwargs = task.params()
|
||||
func(*args, **kwargs)
|
||||
task.delete()
|
||||
|
||||
@disable_logging
|
||||
def run_all_pending_tasks(self, task_function):
|
||||
def run_all_pending_tasks(self, task_function: Any):
|
||||
func = getattr(task_function, 'task_function', None)
|
||||
tasks = Task.objects.all()
|
||||
|
||||
for task in tasks:
|
||||
self.assertEqual(task_function.name, task.task_name)
|
||||
args, kwargs = task.params()
|
||||
func(*args, **kwargs)
|
||||
task.delete()
|
||||
|
||||
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_save_api = create_wayback_machine_save_api_mock()
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()):
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
mock_save_api.save.assert_called_once()
|
||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot')
|
||||
|
||||
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
|
||||
mock_save_api = create_wayback_machine_save_api_mock()
|
||||
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||
tasks._create_web_archive_snapshot_task(123, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
mock_save_api.assert_not_called()
|
||||
mock_save_api.save.assert_not_called()
|
||||
|
||||
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
mock_save_api = create_wayback_machine_save_api_mock()
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
@@ -103,9 +107,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
mock_save_api = create_wayback_machine_save_api_mock(archive_url='https://other.com')
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI('https://other.com')):
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
@@ -114,24 +118,27 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
|
||||
mock_cdx_api = create_cdx_server_api_mock()
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI()):
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=mock_cdx_api):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
mock_cdx_api.newest.assert_called_once()
|
||||
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
|
||||
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=mock_cdx_api):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
@@ -140,51 +147,78 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
|
||||
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=mock_cdx_api):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual('', bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_create_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_save_api = create_wayback_machine_save_api_mock()
|
||||
|
||||
# update bookmark during API call to check that saving
|
||||
# the snapshot does not overwrite updated bookmark data
|
||||
def mock_save_impl():
|
||||
bookmark.title = 'Updated title'
|
||||
bookmark.save()
|
||||
|
||||
mock_save_api.save.side_effect = mock_save_impl
|
||||
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.title, 'Updated title')
|
||||
self.assertEqual('https://example.com/created_snapshot', bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_load_web_archive_snapshot_should_update_snapshot_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_cdx_api = create_cdx_server_api_mock()
|
||||
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI()):
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=mock_cdx_api):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
mock_cdx_api.newest.assert_called_once()
|
||||
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
|
||||
mock_cdx_api = create_cdx_server_api_mock()
|
||||
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=mock_cdx_api):
|
||||
tasks._load_web_archive_snapshot_task(123)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
||||
mock_cdx_api.assert_not_called()
|
||||
mock_cdx_api.newest.assert_not_called()
|
||||
|
||||
def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
mock_cdx_api = create_cdx_server_api_mock()
|
||||
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=mock_cdx_api):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
||||
mock_cdx_api.assert_not_called()
|
||||
mock_cdx_api.newest.assert_not_called()
|
||||
|
||||
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
|
||||
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=mock_cdx_api):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
||||
@@ -192,14 +226,37 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
|
||||
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=mock_cdx_api):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
||||
self.assertEqual('', bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_load_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
mock_cdx_api = create_cdx_server_api_mock()
|
||||
|
||||
# update bookmark during API call to check that saving
|
||||
# the snapshot does not overwrite updated bookmark data
|
||||
def mock_newest_impl():
|
||||
bookmark.title = 'Updated title'
|
||||
bookmark.save()
|
||||
return mock.DEFAULT
|
||||
|
||||
mock_cdx_api.newest.side_effect = mock_newest_impl
|
||||
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=mock_cdx_api):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual('Updated title', bookmark.title)
|
||||
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -262,3 +319,177 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_load_favicon_should_create_favicon_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
|
||||
mock_load_favicon.return_value = 'https_example_com.png'
|
||||
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
self.run_pending_task(tasks._load_favicon_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.favicon_file, 'https_example_com.png')
|
||||
|
||||
def test_load_favicon_should_update_favicon_file(self):
|
||||
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
|
||||
|
||||
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
|
||||
mock_load_favicon.return_value = 'https_example_updated_com.png'
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
self.run_pending_task(tasks._load_favicon_task)
|
||||
|
||||
mock_load_favicon.assert_called()
|
||||
bookmark.refresh_from_db()
|
||||
self.assertEqual(bookmark.favicon_file, 'https_example_updated_com.png')
|
||||
|
||||
def test_load_favicon_should_handle_missing_bookmark(self):
|
||||
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
|
||||
tasks._load_favicon_task(123)
|
||||
self.run_pending_task(tasks._load_favicon_task)
|
||||
|
||||
mock_load_favicon.assert_not_called()
|
||||
|
||||
def test_load_favicon_should_not_save_stale_bookmark_data(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
# update bookmark during API call to check that saving
|
||||
# the favicon does not overwrite updated bookmark data
|
||||
def mock_load_favicon_impl(url):
|
||||
bookmark.title = 'Updated title'
|
||||
bookmark.save()
|
||||
return 'https_example_com.png'
|
||||
|
||||
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
|
||||
mock_load_favicon.side_effect = mock_load_favicon_impl
|
||||
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
self.run_pending_task(tasks._load_favicon_task)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.title, 'Updated title')
|
||||
self.assertEqual(bookmark.favicon_file, 'https_example_com.png')
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_load_favicon_should_not_run_when_background_tasks_are_disabled(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_load_favicon_should_not_run_when_favicon_feature_is_disabled(self):
|
||||
self.user.profile.enable_favicons = False
|
||||
self.user.profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_favicons_should_load_favicon_for_all_bookmarks_without_favicon(self):
|
||||
user = self.get_or_create_test_user()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task)
|
||||
|
||||
task_list = Task.objects.all()
|
||||
self.assertEqual(task_list.count(), 3)
|
||||
|
||||
for task in task_list:
|
||||
self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_favicon_task')
|
||||
|
||||
def test_schedule_bookmarks_without_favicons_should_only_update_user_owned_bookmarks(self):
|
||||
user = self.get_or_create_test_user()
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(user=other_user)
|
||||
self.setup_bookmark(user=other_user)
|
||||
self.setup_bookmark(user=other_user)
|
||||
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task)
|
||||
|
||||
task_list = Task.objects.all()
|
||||
self.assertEqual(task_list.count(), 3)
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_schedule_bookmarks_without_favicons_should_not_run_when_background_tasks_are_disabled(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_favicons_should_not_run_when_favicon_feature_is_disabled(self):
|
||||
self.user.profile.enable_favicons = False
|
||||
self.user.profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_schedule_refresh_favicons_should_update_favicon_for_all_bookmarks(self):
|
||||
user = self.get_or_create_test_user()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||
|
||||
tasks.schedule_refresh_favicons(user)
|
||||
self.run_pending_task(tasks._schedule_refresh_favicons_task)
|
||||
|
||||
task_list = Task.objects.all()
|
||||
self.assertEqual(task_list.count(), 6)
|
||||
|
||||
for task in task_list:
|
||||
self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_favicon_task')
|
||||
|
||||
def test_schedule_refresh_favicons_should_only_update_user_owned_bookmarks(self):
|
||||
user = self.get_or_create_test_user()
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(user=other_user)
|
||||
self.setup_bookmark(user=other_user)
|
||||
self.setup_bookmark(user=other_user)
|
||||
|
||||
tasks.schedule_refresh_favicons(user)
|
||||
self.run_pending_task(tasks._schedule_refresh_favicons_task)
|
||||
|
||||
task_list = Task.objects.all()
|
||||
self.assertEqual(task_list.count(), 3)
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_schedule_refresh_favicons_should_not_run_when_background_tasks_are_disabled(self):
|
||||
self.setup_bookmark()
|
||||
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
@override_settings(LD_ENABLE_REFRESH_FAVICONS=False)
|
||||
def test_schedule_refresh_favicons_should_not_run_when_refresh_is_disabled(self):
|
||||
self.setup_bookmark()
|
||||
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
||||
def test_schedule_refresh_favicons_should_not_run_when_favicon_feature_is_disabled(self):
|
||||
self.user.profile.enable_favicons = False
|
||||
self.user.profile.save()
|
||||
|
||||
self.setup_bookmark()
|
||||
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(Task.objects.count(), 0)
|
||||
|
28
bookmarks/tests/test_exporter.py
Normal file
28
bookmarks/tests/test_exporter.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.services import exporter
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_escape_html_in_title_and_description(self):
|
||||
bookmark = self.setup_bookmark(
|
||||
title='<style>: The Style Information element',
|
||||
description='The <style> HTML element contains style information for a document, or part of a document.'
|
||||
)
|
||||
html = exporter.export_netscape_html([bookmark])
|
||||
|
||||
self.assertIn('<style>: The Style Information element', html)
|
||||
self.assertIn(
|
||||
'The <style> HTML element contains style information for a document, or part of a document.',
|
||||
html
|
||||
)
|
||||
|
||||
def test_handle_empty_values(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.title = ''
|
||||
bookmark.description = ''
|
||||
bookmark.website_title = None
|
||||
bookmark.website_description = None
|
||||
bookmark.save()
|
||||
exporter.export_netscape_html([bookmark])
|
127
bookmarks/tests/test_favicon_loader.py
Normal file
127
bookmarks/tests/test_favicon_loader.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import io
|
||||
import os.path
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.services import favicon_loader
|
||||
|
||||
mock_icon_data = b'mock_icon'
|
||||
|
||||
|
||||
class FaviconLoaderTestCase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ensure_favicon_folder()
|
||||
self.clear_favicon_folder()
|
||||
|
||||
def create_mock_response(self, icon_data=mock_icon_data):
|
||||
mock_response = mock.Mock()
|
||||
mock_response.raw = io.BytesIO(icon_data)
|
||||
return mock_response
|
||||
|
||||
def ensure_favicon_folder(self):
|
||||
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def clear_favicon_folder(self):
|
||||
folder = Path(settings.LD_FAVICON_FOLDER)
|
||||
for file in folder.iterdir():
|
||||
file.unlink()
|
||||
|
||||
def get_icon_path(self, filename):
|
||||
return Path(os.path.join(settings.LD_FAVICON_FOLDER, filename))
|
||||
|
||||
def icon_exists(self, filename):
|
||||
return self.get_icon_path(filename).exists()
|
||||
|
||||
def get_icon_data(self, filename):
|
||||
return self.get_icon_path(filename).read_bytes()
|
||||
|
||||
def count_icons(self):
|
||||
files = os.listdir(settings.LD_FAVICON_FOLDER)
|
||||
return len(files)
|
||||
|
||||
def test_load_favicon(self):
|
||||
with mock.patch('requests.get') as mock_get:
|
||||
mock_get.return_value = self.create_mock_response()
|
||||
favicon_loader.load_favicon('https://example.com')
|
||||
|
||||
# should create icon file
|
||||
self.assertTrue(self.icon_exists('https_example_com.png'))
|
||||
|
||||
# should store image data
|
||||
self.assertEqual(mock_icon_data, self.get_icon_data('https_example_com.png'))
|
||||
|
||||
def test_load_favicon_creates_folder_if_not_exists(self):
|
||||
with mock.patch('requests.get') as mock_get:
|
||||
mock_get.return_value = self.create_mock_response()
|
||||
|
||||
folder = Path(settings.LD_FAVICON_FOLDER)
|
||||
folder.rmdir()
|
||||
|
||||
self.assertFalse(folder.exists())
|
||||
|
||||
favicon_loader.load_favicon('https://example.com')
|
||||
|
||||
self.assertTrue(folder.exists())
|
||||
|
||||
def test_load_favicon_creates_single_icon_for_same_base_url(self):
|
||||
with mock.patch('requests.get') as mock_get:
|
||||
mock_get.return_value = self.create_mock_response()
|
||||
favicon_loader.load_favicon('https://example.com')
|
||||
favicon_loader.load_favicon('https://example.com?foo=bar')
|
||||
favicon_loader.load_favicon('https://example.com/foo')
|
||||
|
||||
self.assertEqual(1, self.count_icons())
|
||||
self.assertTrue(self.icon_exists('https_example_com.png'))
|
||||
|
||||
def test_load_favicon_creates_multiple_icons_for_different_base_url(self):
|
||||
with mock.patch('requests.get') as mock_get:
|
||||
mock_get.return_value = self.create_mock_response()
|
||||
favicon_loader.load_favicon('https://example.com')
|
||||
favicon_loader.load_favicon('https://sub.example.com')
|
||||
favicon_loader.load_favicon('https://other-domain.com')
|
||||
|
||||
self.assertEqual(3, self.count_icons())
|
||||
self.assertTrue(self.icon_exists('https_example_com.png'))
|
||||
self.assertTrue(self.icon_exists('https_sub_example_com.png'))
|
||||
self.assertTrue(self.icon_exists('https_other_domain_com.png'))
|
||||
|
||||
def test_load_favicon_caches_icons(self):
|
||||
with mock.patch('requests.get') as mock_get:
|
||||
mock_get.return_value = self.create_mock_response()
|
||||
|
||||
favicon_loader.load_favicon('https://example.com')
|
||||
mock_get.assert_called()
|
||||
|
||||
mock_get.reset_mock()
|
||||
favicon_loader.load_favicon('https://example.com')
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_load_favicon_updates_stale_icon(self):
|
||||
with mock.patch('requests.get') as mock_get:
|
||||
mock_get.return_value = self.create_mock_response()
|
||||
favicon_loader.load_favicon('https://example.com')
|
||||
|
||||
icon_path = self.get_icon_path('https_example_com.png')
|
||||
|
||||
updated_mock_icon_data = b'updated_mock_icon'
|
||||
mock_get.return_value = self.create_mock_response(icon_data=updated_mock_icon_data)
|
||||
mock_get.reset_mock()
|
||||
|
||||
# change icon modification date so it is not stale yet
|
||||
nearly_one_day_ago = time.time() - 60 * 60 * 23
|
||||
os.utime(icon_path.absolute(), (nearly_one_day_ago, nearly_one_day_ago))
|
||||
|
||||
favicon_loader.load_favicon('https://example.com')
|
||||
mock_get.assert_not_called()
|
||||
|
||||
# change icon modification date so it is considered stale
|
||||
one_day_ago = time.time() - 60 * 60 * 24
|
||||
os.utime(icon_path.absolute(), (one_day_ago, one_day_ago))
|
||||
|
||||
favicon_loader.load_favicon('https://example.com')
|
||||
mock_get.assert_called()
|
||||
self.assertEqual(updated_mock_icon_data, self.get_icon_data('https_example_com.png'))
|
36
bookmarks/tests/test_health_view.py
Normal file
36
bookmarks/tests/test_health_view.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.db import connections
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.views.settings import app_version
|
||||
|
||||
|
||||
class HealthViewTestCase(TestCase):
|
||||
|
||||
def test_health_healthy(self):
|
||||
response = self.client.get("/health")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_body = response.json()
|
||||
expected_body = {
|
||||
'version': app_version,
|
||||
'status': 'healthy'
|
||||
}
|
||||
self.assertDictEqual(response_body, expected_body)
|
||||
|
||||
def test_health_unhealhty(self):
|
||||
with patch.object(connections['default'], 'ensure_connection') as mock_ensure_connection:
|
||||
mock_ensure_connection.side_effect = Exception('Connection error')
|
||||
|
||||
response = self.client.get("/health")
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
response_body = response.json()
|
||||
expected_body = {
|
||||
'version': app_version,
|
||||
'status': 'unhealthy'
|
||||
}
|
||||
self.assertDictEqual(response_body, expected_body)
|
@@ -262,3 +262,12 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
import_netscape_html(test_html, user)
|
||||
|
||||
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
|
||||
|
||||
def test_schedule_favicon_loading(self):
|
||||
user = self.get_or_create_test_user()
|
||||
test_html = self.render_html(tags_html='')
|
||||
|
||||
with patch.object(tasks, 'schedule_bookmarks_without_favicons') as mock_schedule_bookmarks_without_favicons:
|
||||
import_netscape_html(test_html, user)
|
||||
|
||||
mock_schedule_bookmarks_without_favicons.assert_called_once_with(user)
|
||||
|
@@ -5,7 +5,7 @@ from django.db.models import QuerySet
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.models import Bookmark, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence
|
||||
from bookmarks.utils import unique
|
||||
|
||||
@@ -13,6 +13,8 @@ User = get_user_model()
|
||||
|
||||
|
||||
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self):
|
||||
self.profile = self.get_or_create_test_user().profile
|
||||
|
||||
def setup_bookmark_search_data(self) -> None:
|
||||
tag1 = self.setup_tag(name='tag1')
|
||||
@@ -27,9 +29,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.term1_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1'),
|
||||
self.setup_bookmark(title=random_sentence(including_word='term1')),
|
||||
self.setup_bookmark(title=random_sentence(including_word='TERM1')),
|
||||
self.setup_bookmark(description=random_sentence(including_word='term1')),
|
||||
self.setup_bookmark(description=random_sentence(including_word='TERM1')),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='term1')),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='TERM1')),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='term1')),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='TERM1')),
|
||||
]
|
||||
self.term1_term2_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1/term2'),
|
||||
@@ -49,6 +55,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(website_title=random_sentence(), tags=[tag1]),
|
||||
self.setup_bookmark(website_description=random_sentence(), tags=[tag1]),
|
||||
]
|
||||
self.tag1_as_term_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/tag1'),
|
||||
self.setup_bookmark(title=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(description=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='tag1')),
|
||||
]
|
||||
self.term1_tag1_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1', tags=[tag1]),
|
||||
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[tag1]),
|
||||
@@ -76,9 +89,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.term1_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1', tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(title=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(description=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
|
||||
]
|
||||
self.term1_term2_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1/term2', tags=[self.setup_tag()]),
|
||||
@@ -102,6 +119,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(website_title=random_sentence(), tags=[tag1, self.setup_tag()]),
|
||||
self.setup_bookmark(website_description=random_sentence(), tags=[tag1, self.setup_tag()]),
|
||||
]
|
||||
self.tag1_as_term_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/tag1'),
|
||||
self.setup_bookmark(title=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(description=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='tag1')),
|
||||
]
|
||||
self.term1_tag1_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1', tags=[tag1, self.setup_tag()]),
|
||||
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[tag1, self.setup_tag()]),
|
||||
@@ -135,12 +159,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmarks_should_return_all_for_empty_query(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '')
|
||||
self.assertQueryResult(query, [
|
||||
self.other_bookmarks,
|
||||
self.term1_bookmarks,
|
||||
self.term1_term2_bookmarks,
|
||||
self.tag1_bookmarks,
|
||||
self.tag1_as_term_bookmarks,
|
||||
self.term1_tag1_bookmarks,
|
||||
self.tag2_bookmarks,
|
||||
self.tag1_tag2_bookmarks
|
||||
@@ -149,7 +174,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmarks_should_search_single_term(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term1')
|
||||
self.assertQueryResult(query, [
|
||||
self.term1_bookmarks,
|
||||
self.term1_term2_bookmarks,
|
||||
@@ -159,63 +184,101 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmarks_should_search_multiple_terms(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term2 term1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term2 term1')
|
||||
|
||||
self.assertQueryResult(query, [self.term1_term2_bookmarks])
|
||||
|
||||
def test_query_bookmarks_should_search_single_tag(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#tag1')
|
||||
|
||||
self.assertQueryResult(query, [self.tag1_bookmarks, self.tag1_tag2_bookmarks, self.term1_tag1_bookmarks])
|
||||
|
||||
def test_query_bookmarks_should_search_multiple_tags(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1 #tag2')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#tag1 #tag2')
|
||||
|
||||
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
|
||||
|
||||
def test_query_bookmarks_should_search_multiple_tags_ignoring_casing(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#Tag1 #TAG2')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#Tag1 #TAG2')
|
||||
|
||||
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
|
||||
|
||||
def test_query_bookmarks_should_search_terms_and_tags_combined(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #tag1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term1 #tag1')
|
||||
|
||||
self.assertQueryResult(query, [self.term1_tag1_bookmarks])
|
||||
|
||||
def test_query_bookmarks_in_strict_mode_should_not_search_tags_as_terms(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT
|
||||
self.profile.save()
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'tag1')
|
||||
self.assertQueryResult(query, [self.tag1_as_term_bookmarks])
|
||||
|
||||
def test_query_bookmarks_in_lax_mode_should_search_tags_as_terms(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
self.profile.save()
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'tag1')
|
||||
self.assertQueryResult(query, [
|
||||
self.tag1_bookmarks,
|
||||
self.tag1_as_term_bookmarks,
|
||||
self.tag1_tag2_bookmarks,
|
||||
self.term1_tag1_bookmarks
|
||||
])
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'tag1 term1')
|
||||
self.assertQueryResult(query, [
|
||||
self.term1_tag1_bookmarks,
|
||||
])
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'tag1 tag2')
|
||||
self.assertQueryResult(query, [
|
||||
self.tag1_tag2_bookmarks,
|
||||
])
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'tag1 #tag2')
|
||||
self.assertQueryResult(query, [
|
||||
self.tag1_tag2_bookmarks,
|
||||
])
|
||||
|
||||
def test_query_bookmarks_should_return_no_matches(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term3')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 term3')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term1 term3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #tag2')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term1 #tag2')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag3')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#tag3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#unused_tag1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag combined with tag that is used
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1 #unused_tag1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#tag1 #unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag combined with term that is used
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #unused_tag1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term1 #unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
|
||||
@@ -225,7 +288,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True)
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[bookmark1, bookmark2]])
|
||||
|
||||
@@ -236,7 +299,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_archived_bookmarks(self.get_or_create_test_user(), '')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[bookmark1, bookmark2]])
|
||||
|
||||
@@ -251,7 +314,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=other_user)
|
||||
self.setup_bookmark(user=other_user)
|
||||
|
||||
query = queries.query_bookmarks(self.user, '')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [owned_bookmarks])
|
||||
|
||||
@@ -266,7 +329,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, user=other_user)
|
||||
self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [owned_bookmarks])
|
||||
|
||||
@@ -276,7 +339,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmarks(self.user, '!untagged')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '!untagged')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_bookmarks_untagged_should_be_combinable_with_search_terms(self):
|
||||
@@ -285,7 +348,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(title='term2')
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmarks(self.user, '!untagged term1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_bookmarks_untagged_should_not_be_combinable_with_tags(self):
|
||||
@@ -294,7 +357,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmarks(self.user, f'!untagged #{tag.name}')
|
||||
query = queries.query_bookmarks(self.user, self.profile, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_archived_bookmarks_untagged_should_return_untagged_bookmarks_only(self):
|
||||
@@ -303,7 +366,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '!untagged')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, '!untagged')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_archived_bookmarks_untagged_should_be_combinable_with_search_terms(self):
|
||||
@@ -312,7 +375,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, title='term2')
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '!untagged term1')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_archived_bookmarks_untagged_should_not_be_combinable_with_tags(self):
|
||||
@@ -321,7 +384,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, f'!untagged #{tag.name}')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_bookmarks_unread_should_return_unread_bookmarks_only(self):
|
||||
@@ -334,7 +397,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_bookmarks(self.user, '!unread')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '!unread')
|
||||
self.assertCountEqual(list(query), unread_bookmarks)
|
||||
|
||||
def test_query_archived_bookmarks_unread_should_return_unread_bookmarks_only(self):
|
||||
@@ -347,13 +410,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True)
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '!unread')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, '!unread')
|
||||
self.assertCountEqual(list(query), unread_bookmarks)
|
||||
|
||||
def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.other_bookmarks),
|
||||
@@ -368,7 +431,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_single_term(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, 'term1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term1')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.term1_bookmarks),
|
||||
@@ -379,7 +442,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_multiple_terms(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, 'term2 term1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term2 term1')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
|
||||
@@ -388,7 +451,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_single_tag(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '#tag1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_bookmarks),
|
||||
@@ -399,7 +462,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_multiple_tags(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '#tag1 #tag2')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1 #tag2')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
|
||||
@@ -408,7 +471,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_multiple_tags_ignoring_casing(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '#Tag1 #TAG2')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#Tag1 #TAG2')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
|
||||
@@ -417,37 +480,75 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_term_and_tag_combined(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, 'term1 #tag1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #tag1')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
|
||||
])
|
||||
|
||||
def test_query_bookmark_tags_in_strict_mode_should_not_search_tags_as_terms(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT
|
||||
self.profile.save()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1')
|
||||
self.assertQueryResult(query, self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks))
|
||||
|
||||
def test_query_bookmark_tags_in_lax_mode_should_search_tags_as_terms(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
self.profile.save()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1')
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_bookmarks),
|
||||
self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks),
|
||||
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
|
||||
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks)
|
||||
])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 term1')
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
|
||||
])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 tag2')
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
|
||||
])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 #tag2')
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
|
||||
])
|
||||
|
||||
def test_query_bookmark_tags_should_return_no_matches(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term3')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 term3')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 term3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 #tag2')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #tag2')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#tag3')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#tag3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#unused_tag1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag combined with tag that is used
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#tag1 #unused_tag1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1 #unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag combined with term that is used
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 #unused_tag1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):
|
||||
@@ -457,7 +558,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(is_archived=True, tags=[tag2])
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[tag1]])
|
||||
|
||||
@@ -467,7 +568,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[tag]])
|
||||
|
||||
@@ -478,7 +579,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(is_archived=True, tags=[tag2])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[tag2]])
|
||||
|
||||
@@ -488,7 +589,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[tag]])
|
||||
|
||||
@@ -503,7 +604,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
|
||||
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
|
||||
|
||||
@@ -518,7 +619,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
|
||||
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
|
||||
|
||||
@@ -529,13 +630,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(title='term1', tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '!untagged')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '!untagged')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '!untagged term1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, f'!untagged #{tag.name}')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_archived_bookmark_tags_untagged_should_never_return_any_tags(self):
|
||||
@@ -545,13 +646,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, title='term1', tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '!untagged')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, '!untagged')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '!untagged term1')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, f'!untagged #{tag.name}')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_shared_bookmarks(self):
|
||||
@@ -574,14 +675,14 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=user4, shared=True, tags=[tag]),
|
||||
|
||||
# Should return shared bookmarks from all users
|
||||
query_set = queries.query_shared_bookmarks(None, '')
|
||||
query_set = queries.query_shared_bookmarks(None, self.profile, '')
|
||||
self.assertQueryResult(query_set, [shared_bookmarks])
|
||||
|
||||
# Should respect search query
|
||||
query_set = queries.query_shared_bookmarks(None, 'test title')
|
||||
query_set = queries.query_shared_bookmarks(None, self.profile, 'test title')
|
||||
self.assertQueryResult(query_set, [[shared_bookmarks[0]]])
|
||||
|
||||
query_set = queries.query_shared_bookmarks(None, '#' + tag.name)
|
||||
query_set = queries.query_shared_bookmarks(None, self.profile, '#' + tag.name)
|
||||
self.assertQueryResult(query_set, [[shared_bookmarks[2]]])
|
||||
|
||||
def test_query_shared_bookmark_tags(self):
|
||||
@@ -605,7 +706,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=user3, shared=False, tags=[self.setup_tag(user=user3)]),
|
||||
self.setup_bookmark(user=user4, shared=True, tags=[self.setup_tag(user=user4)]),
|
||||
|
||||
query_set = queries.query_shared_bookmark_tags(None, '')
|
||||
query_set = queries.query_shared_bookmark_tags(None, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query_set, [shared_tags])
|
||||
|
||||
@@ -630,9 +731,9 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True),
|
||||
|
||||
# Should return users with shared bookmarks
|
||||
query_set = queries.query_shared_bookmark_users('')
|
||||
query_set = queries.query_shared_bookmark_users(self.profile, '')
|
||||
self.assertQueryResult(query_set, [users_with_shared_bookmarks])
|
||||
|
||||
# Should respect search query
|
||||
query_set = queries.query_shared_bookmark_users('test title')
|
||||
query_set = queries.query_shared_bookmark_users(self.profile, 'test title')
|
||||
self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]])
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import random
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
import requests
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from requests import RequestException
|
||||
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
from bookmarks.views.settings import app_version, get_version_info
|
||||
|
||||
@@ -17,6 +18,22 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def create_profile_form_data(self, overrides=None):
|
||||
if not overrides:
|
||||
overrides = {}
|
||||
form_data = {
|
||||
'theme': UserProfile.THEME_AUTO,
|
||||
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_BLANK,
|
||||
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
|
||||
'enable_sharing': False,
|
||||
'enable_favicons': False,
|
||||
'tag_search': UserProfile.TAG_SEARCH_STRICT,
|
||||
'display_url': False,
|
||||
}
|
||||
|
||||
return {**form_data, **overrides}
|
||||
|
||||
def test_should_render_successfully(self):
|
||||
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||
|
||||
@@ -28,15 +45,20 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general'))
|
||||
|
||||
def test_should_save_profile(self):
|
||||
def test_update_profile(self):
|
||||
form_data = {
|
||||
'update_profile': '',
|
||||
'theme': UserProfile.THEME_DARK,
|
||||
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
|
||||
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
|
||||
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
|
||||
'enable_sharing': True,
|
||||
'enable_favicons': True,
|
||||
'tag_search': UserProfile.TAG_SEARCH_LAX,
|
||||
'display_url': True,
|
||||
}
|
||||
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||
html = response.content.decode()
|
||||
|
||||
self.user.profile.refresh_from_db()
|
||||
|
||||
@@ -46,6 +68,120 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
|
||||
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
|
||||
self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing'])
|
||||
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
|
||||
self.assertEqual(self.user.profile.tag_search, form_data['tag_search'])
|
||||
self.assertEqual(self.user.profile.display_url, form_data['display_url'])
|
||||
self.assertInHTML('''
|
||||
<p class="form-input-hint">Profile updated</p>
|
||||
''', html)
|
||||
|
||||
def test_update_profile_should_not_be_called_without_respective_form_action(self):
|
||||
form_data = {
|
||||
'theme': UserProfile.THEME_DARK,
|
||||
}
|
||||
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||
html = response.content.decode()
|
||||
|
||||
self.user.profile.refresh_from_db()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
|
||||
self.assertInHTML('''
|
||||
<p class="form-input-hint">Profile updated</p>
|
||||
''', html, count=0)
|
||||
|
||||
def test_enable_favicons_should_schedule_icon_update(self):
|
||||
with patch.object(tasks, 'schedule_bookmarks_without_favicons') as mock_schedule_bookmarks_without_favicons:
|
||||
# Enabling favicons schedules update
|
||||
form_data = self.create_profile_form_data({
|
||||
'update_profile': '',
|
||||
'enable_favicons': True,
|
||||
})
|
||||
self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||
|
||||
mock_schedule_bookmarks_without_favicons.assert_called_once_with(self.user)
|
||||
|
||||
# No update scheduled if favicons are already enabled
|
||||
mock_schedule_bookmarks_without_favicons.reset_mock()
|
||||
|
||||
self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||
|
||||
mock_schedule_bookmarks_without_favicons.assert_not_called()
|
||||
|
||||
# No update scheduled when disabling favicons
|
||||
form_data = self.create_profile_form_data({
|
||||
'enable_favicons': False,
|
||||
})
|
||||
|
||||
self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||
|
||||
mock_schedule_bookmarks_without_favicons.assert_not_called()
|
||||
|
||||
def test_refresh_favicons(self):
|
||||
with patch.object(tasks, 'schedule_refresh_favicons') as mock_schedule_refresh_favicons:
|
||||
form_data = {
|
||||
'refresh_favicons': '',
|
||||
}
|
||||
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||
html = response.content.decode()
|
||||
|
||||
mock_schedule_refresh_favicons.assert_called_once()
|
||||
self.assertInHTML('''
|
||||
<p class="form-input-hint">
|
||||
Scheduled favicon update. This may take a while...
|
||||
</p>
|
||||
''', html)
|
||||
|
||||
def test_refresh_favicons_should_not_be_called_without_respective_form_action(self):
|
||||
with patch.object(tasks, 'schedule_refresh_favicons') as mock_schedule_refresh_favicons:
|
||||
form_data = {
|
||||
}
|
||||
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||
html = response.content.decode()
|
||||
|
||||
mock_schedule_refresh_favicons.assert_not_called()
|
||||
self.assertInHTML('''
|
||||
<p class="form-input-hint">
|
||||
Scheduled favicon update. This may take a while...
|
||||
</p>
|
||||
''', html, count=0)
|
||||
|
||||
def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_favicons = True
|
||||
profile.save()
|
||||
|
||||
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('''
|
||||
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||
''', html, count=1)
|
||||
|
||||
def test_refresh_favicons_should_not_be_visible_when_favicons_disabled_in_profile(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_favicons = False
|
||||
profile.save()
|
||||
|
||||
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('''
|
||||
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||
''', html, count=0)
|
||||
|
||||
@override_settings(LD_ENABLE_REFRESH_FAVICONS=False)
|
||||
def test_refresh_favicons_should_not_be_visible_when_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_favicons = True
|
||||
profile.save()
|
||||
|
||||
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('''
|
||||
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||
''', html, count=0)
|
||||
|
||||
def test_about_shows_version_info(self):
|
||||
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||
@@ -59,13 +195,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 +210,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)
|
||||
|
@@ -3,7 +3,7 @@ from typing import List
|
||||
from django.template import Template, RequestContext
|
||||
from django.test import TestCase, RequestFactory
|
||||
|
||||
from bookmarks.models import Tag
|
||||
from bookmarks.models import Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
rf = RequestFactory()
|
||||
request = rf.get(url)
|
||||
request.user = self.get_or_create_test_user()
|
||||
context = RequestContext(request, {
|
||||
'request': request,
|
||||
'tags': tags,
|
||||
@@ -118,6 +119,36 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
</a>
|
||||
''', rendered_template)
|
||||
|
||||
def test_selected_tags_with_lax_tag_search(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
profile.save()
|
||||
|
||||
tags = [
|
||||
self.setup_tag(name='tag1'),
|
||||
self.setup_tag(name='tag2'),
|
||||
]
|
||||
|
||||
# Filter by tag name without hash
|
||||
rendered_template = self.render_template(tags, tags, url='/test?q=tag1 %23tag2')
|
||||
|
||||
self.assertNumSelectedTags(rendered_template, 2)
|
||||
|
||||
# Tag name should still be removed from query string
|
||||
self.assertInHTML('''
|
||||
<a href="?q=%23tag2"
|
||||
class="text-bold mr-2">
|
||||
<span>-tag1</span>
|
||||
</a>
|
||||
''', rendered_template)
|
||||
|
||||
self.assertInHTML('''
|
||||
<a href="?q=tag1"
|
||||
class="text-bold mr-2">
|
||||
<span>-tag2</span>
|
||||
</a>
|
||||
''', rendered_template)
|
||||
|
||||
def test_selected_tags_ignore_casing_when_removing_query_part(self):
|
||||
tags = [
|
||||
self.setup_tag(name='TEST'),
|
||||
|
84
bookmarks/tests/test_website_loader.py
Normal file
84
bookmarks/tests/test_website_loader.py
Normal file
@@ -0,0 +1,84 @@
|
||||
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 setUp(self):
|
||||
# clear cached metadata before test run
|
||||
website_loader.load_website_metadata.cache_clear()
|
||||
|
||||
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)
|
@@ -31,4 +31,6 @@ urlpatterns = [
|
||||
# Feeds
|
||||
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
|
||||
path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'),
|
||||
# Health check
|
||||
path('health', views.health, name='health')
|
||||
]
|
||||
|
@@ -1,3 +1,4 @@
|
||||
from .bookmarks import *
|
||||
from .settings import *
|
||||
from .toasts import *
|
||||
from .health import health
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import urllib.parse
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
@@ -9,7 +10,7 @@ from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, User, Tag, build_tag_string
|
||||
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, User, UserProfile, Tag, build_tag_string
|
||||
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.utils import get_safe_return_url
|
||||
@@ -20,8 +21,8 @@ _default_page_size = 30
|
||||
@login_required
|
||||
def index(request):
|
||||
filters = BookmarkFilters(request)
|
||||
query_set = queries.query_bookmarks(request.user, filters.query)
|
||||
tags = queries.query_bookmark_tags(request.user, filters.query)
|
||||
query_set = queries.query_bookmarks(request.user, request.user.profile, filters.query)
|
||||
tags = queries.query_bookmark_tags(request.user, request.user.profile, filters.query)
|
||||
base_url = reverse('bookmarks:index')
|
||||
context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
|
||||
return render(request, 'bookmarks/index.html', context)
|
||||
@@ -30,8 +31,8 @@ def index(request):
|
||||
@login_required
|
||||
def archived(request):
|
||||
filters = BookmarkFilters(request)
|
||||
query_set = queries.query_archived_bookmarks(request.user, filters.query)
|
||||
tags = queries.query_archived_bookmark_tags(request.user, filters.query)
|
||||
query_set = queries.query_archived_bookmarks(request.user, request.user.profile, filters.query)
|
||||
tags = queries.query_archived_bookmark_tags(request.user, request.user.profile, filters.query)
|
||||
base_url = reverse('bookmarks:archived')
|
||||
context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
|
||||
return render(request, 'bookmarks/archive.html', context)
|
||||
@@ -41,27 +42,23 @@ def archived(request):
|
||||
def shared(request):
|
||||
filters = BookmarkFilters(request)
|
||||
user = User.objects.filter(username=filters.user).first()
|
||||
query_set = queries.query_shared_bookmarks(user, filters.query)
|
||||
tags = queries.query_shared_bookmark_tags(user, filters.query)
|
||||
users = queries.query_shared_bookmark_users(filters.query)
|
||||
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query)
|
||||
tags = queries.query_shared_bookmark_tags(user, request.user.profile, filters.query)
|
||||
users = queries.query_shared_bookmark_users(request.user.profile, filters.query)
|
||||
base_url = reverse('bookmarks:shared')
|
||||
context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
|
||||
context['users'] = users
|
||||
return render(request, 'bookmarks/shared.html', context)
|
||||
|
||||
|
||||
def _get_selected_tags(tags: QuerySet[Tag], query_string: str):
|
||||
def _get_selected_tags(tags: List[Tag], query_string: str, profile: UserProfile):
|
||||
parsed_query = queries.parse_query_string(query_string)
|
||||
tag_names = parsed_query['tag_names']
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
tag_names = tag_names + parsed_query['search_terms']
|
||||
tag_names = [tag_name.lower() for tag_name in tag_names]
|
||||
|
||||
if len(tag_names) == 0:
|
||||
return []
|
||||
|
||||
condition = Q()
|
||||
for tag_name in parsed_query['tag_names']:
|
||||
condition = condition | Q(name__iexact=tag_name)
|
||||
|
||||
return list(tags.filter(condition))
|
||||
return [tag for tag in tags if tag.name.lower() in tag_names]
|
||||
|
||||
|
||||
def get_bookmark_view_context(request: WSGIRequest,
|
||||
@@ -72,7 +69,8 @@ def get_bookmark_view_context(request: WSGIRequest,
|
||||
page = request.GET.get('page')
|
||||
paginator = Paginator(query_set, _default_page_size)
|
||||
bookmarks = paginator.get_page(page)
|
||||
selected_tags = _get_selected_tags(tags, filters.query)
|
||||
tags = list(tags)
|
||||
selected_tags = _get_selected_tags(tags, filters.query, request.user.profile)
|
||||
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
|
||||
prefetch_related_objects(bookmarks.object_list, 'owner', 'tags')
|
||||
return_url = generate_return_url(base_url, page, filters)
|
||||
|
20
bookmarks/views/health.py
Normal file
20
bookmarks/views/health.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.db import connections
|
||||
from django.http import JsonResponse
|
||||
|
||||
from bookmarks.views.settings import app_version
|
||||
|
||||
|
||||
def health(request):
|
||||
code = 200
|
||||
response = {
|
||||
'version': app_version,
|
||||
'status': 'healthy'
|
||||
}
|
||||
|
||||
try:
|
||||
connections['default'].ensure_connection()
|
||||
except Exception:
|
||||
response['status'] = 'unhealthy'
|
||||
code = 500
|
||||
|
||||
return JsonResponse(response, status=code)
|
@@ -3,6 +3,7 @@ import time
|
||||
from functools import lru_cache
|
||||
|
||||
import requests
|
||||
from django.conf import settings as django_settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import prefetch_related_objects
|
||||
@@ -13,7 +14,7 @@ from rest_framework.authtoken.models import Token
|
||||
|
||||
from bookmarks.models import UserProfileForm, FeedToken
|
||||
from bookmarks.queries import query_bookmarks
|
||||
from bookmarks.services import exporter
|
||||
from bookmarks.services import exporter, tasks
|
||||
from bookmarks.services import importer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,24 +29,48 @@ except Exception as exc:
|
||||
|
||||
@login_required
|
||||
def general(request):
|
||||
if request.method == 'POST':
|
||||
form = UserProfileForm(request.POST, instance=request.user.profile)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
else:
|
||||
form = UserProfileForm(instance=request.user.profile)
|
||||
|
||||
profile_form = None
|
||||
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
|
||||
update_profile_success_message = None
|
||||
refresh_favicons_success_message = None
|
||||
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())
|
||||
|
||||
if request.method == 'POST':
|
||||
if 'update_profile' in request.POST:
|
||||
profile_form = update_profile(request)
|
||||
update_profile_success_message = 'Profile updated'
|
||||
if 'refresh_favicons' in request.POST:
|
||||
tasks.schedule_refresh_favicons(request.user)
|
||||
refresh_favicons_success_message = 'Scheduled favicon update. This may take a while...'
|
||||
|
||||
if not profile_form:
|
||||
profile_form = UserProfileForm(instance=request.user.profile)
|
||||
|
||||
return render(request, 'settings/general.html', {
|
||||
'form': form,
|
||||
'form': profile_form,
|
||||
'enable_refresh_favicons': enable_refresh_favicons,
|
||||
'update_profile_success_message': update_profile_success_message,
|
||||
'refresh_favicons_success_message': refresh_favicons_success_message,
|
||||
'import_success_message': import_success_message,
|
||||
'import_errors_message': import_errors_message,
|
||||
'version_info': version_info,
|
||||
})
|
||||
|
||||
|
||||
def update_profile(request):
|
||||
user = request.user
|
||||
profile = user.profile
|
||||
favicons_were_enabled = profile.enable_favicons
|
||||
form = UserProfileForm(request.POST, instance=profile)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
if profile.enable_favicons and not favicons_were_enabled:
|
||||
tasks.schedule_bookmarks_without_favicons(request.user)
|
||||
return form
|
||||
|
||||
|
||||
# Cache API call response, for one hour when using get_ttl_hash with default params
|
||||
@lru_cache(maxsize=1)
|
||||
def get_version_info(ttl_hash=None):
|
||||
@@ -54,7 +79,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
|
||||
|
||||
@@ -115,7 +141,7 @@ def bookmark_import(request):
|
||||
def bookmark_export(request):
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
bookmarks = list(query_bookmarks(request.user, ''))
|
||||
bookmarks = list(query_bookmarks(request.user, request.user.profile, ''))
|
||||
# Prefetch tags to prevent n+1 queries
|
||||
prefetch_related_objects(bookmarks, 'tags')
|
||||
file_content = exporter.export_netscape_html(bookmarks)
|
||||
|
@@ -5,6 +5,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
|
||||
|
||||
# Create data folder if it does not exist
|
||||
mkdir -p data
|
||||
# Create favicon folder if it does not exist
|
||||
mkdir -p data/favicons
|
||||
|
||||
# Run database migration
|
||||
python manage.py migrate
|
||||
@@ -22,4 +24,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
|
||||
|
@@ -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,83 @@ 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_LOG_X_FORWARDED_FOR`
|
||||
|
||||
Values: `true` or `false` | Default = `false`
|
||||
|
||||
Set uWSGI [log-x-forwarded-for](https://uwsgi-docs.readthedocs.io/en/latest/Options.html?#log-x-forwarded-for) parameter allowing to keep the real IP of clients in logs when using a reverse proxy.
|
||||
|
||||
### `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.
|
||||
|
||||
### `LD_DB_OPTIONS`
|
||||
|
||||
Values: `String` | Default = `{}`
|
||||
|
||||
A json string with additional options for the database. Passed directly to OPTIONS.
|
||||
|
||||
### `LD_FAVICON_PROVIDER`
|
||||
|
||||
Values: `String` | Default = `https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON`
|
||||
|
||||
The favicon provider used for downloading icons if they are enabled in the user profile settings.
|
||||
The default provider is a Google service that automatically detects the correct favicon for a website, and provides icons in consistent image format (PNG) and in a consistent image size.
|
||||
|
||||
This setting allows to configure a custom provider in form of a URL.
|
||||
When calling the provider with the URL of a website, it must return the image data for the favicon of that website.
|
||||
The configured favicon provider URL must contain a `{url}` placeholder that will be replaced with the URL of the website for which to download the favicon.
|
||||
See the default URL for an example.
|
||||
|
@@ -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.
|
||||
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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
16
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.15.0",
|
||||
"version": "1.18.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@@ -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.9
|
||||
django-generate-secret-key==1.0.2
|
||||
django-registration==3.3
|
||||
django-sass-processor==1.2.1
|
||||
@@ -12,11 +12,12 @@ 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
|
||||
soupsieve==2.3.2.post1
|
||||
sqlparse==0.4.2
|
||||
sqlparse==0.4.4
|
||||
supervisor==4.2.4
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.11
|
||||
|
@@ -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.9
|
||||
django-appconf==1.0.5
|
||||
django-compressor==4.1
|
||||
django-debug-toolbar==3.6.0
|
||||
@@ -15,8 +15,12 @@ django-sass-processor==1.2.1
|
||||
django-widget-tweaks==1.4.12
|
||||
django4-background-tasks==1.2.7
|
||||
djangorestframework==3.13.1
|
||||
greenlet==2.0.1
|
||||
idna==3.3
|
||||
libsass==0.21.0
|
||||
playwright==1.29.1
|
||||
psycopg2-binary==2.9.5
|
||||
pyee==9.0.4
|
||||
python-dateutil==2.8.2
|
||||
pytz==2022.2.1
|
||||
rcssmin==1.1.0
|
||||
@@ -24,7 +28,7 @@ requests==2.28.1
|
||||
rjsmin==1.2.0
|
||||
six==1.16.0
|
||||
soupsieve==2.3.2.post1
|
||||
sqlparse==0.4.2
|
||||
sqlparse==0.4.4
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.11
|
||||
waybackpy==3.0.6
|
||||
|
@@ -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({
|
||||
|
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/2.2/ref/settings/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
@@ -79,16 +80,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 +129,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,10 +138,10 @@ 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'),
|
||||
os.path.join(BASE_DIR, 'data', 'favicons'),
|
||||
]
|
||||
|
||||
# REST framework
|
||||
@@ -199,3 +190,46 @@ 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)
|
||||
LD_DB_OPTIONS = json.loads(os.getenv('LD_DB_OPTIONS') or '{}')
|
||||
|
||||
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,
|
||||
'OPTIONS': LD_DB_OPTIONS,
|
||||
}
|
||||
else:
|
||||
default_database = {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': os.path.join(BASE_DIR, 'data', 'db.sqlite3'),
|
||||
'OPTIONS': LD_DB_OPTIONS,
|
||||
}
|
||||
|
||||
DATABASES = {
|
||||
'default': default_database
|
||||
}
|
||||
|
||||
# Favicons
|
||||
LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON'
|
||||
LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER)
|
||||
LD_FAVICON_FOLDER = os.path.join(BASE_DIR, 'data', 'favicons')
|
||||
LD_ENABLE_REFRESH_FAVICONS = os.getenv('LD_ENABLE_REFRESH_FAVICONS', True) in (True, 'True', '1')
|
||||
|
@@ -25,7 +25,7 @@ LOGGING = {
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'simple': {
|
||||
'format': '{levelname} {message}',
|
||||
'format': '{levelname} {asctime} {module}: {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
|
10
uwsgi.ini
10
uwsgi.ini
@@ -1,8 +1,8 @@
|
||||
[uwsgi]
|
||||
chdir = /etc/linkding
|
||||
module = siteroot.wsgi:application
|
||||
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
|
||||
static-map = /static=static
|
||||
static-map = /static=data/favicons
|
||||
processes = 2
|
||||
threads = 2
|
||||
pidfile = /tmp/linkding.pid
|
||||
@@ -11,13 +11,19 @@ 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
|
||||
static-map = /%(_)static=data/favicons
|
||||
endif =
|
||||
|
||||
if-env = LD_REQUEST_TIMEOUT
|
||||
http-timeout = %(_)
|
||||
socket-timeout = %(_)
|
||||
harakiri = %(_)
|
||||
endif =
|
||||
endif =
|
||||
|
||||
if-env = LD_LOG_X_FORWARDED_FOR
|
||||
log-x-forwarded-for = %(_)
|
||||
endif =
|
||||
|
@@ -1 +1 @@
|
||||
1.15.0
|
||||
1.18.0
|
||||
|
Reference in New Issue
Block a user