Compare commits

...

52 Commits

Author SHA1 Message Date
Sascha Ißbrücker
5b2a2c2b0d Bump version 2025-03-06 19:39:51 +01:00
Sascha Ißbrücker
988468f3e5 fix path context 2025-03-06 19:35:35 +01:00
Sascha Ißbrücker
3ac0503843 Add documentation for archiving web pages (#1004) 2025-03-06 19:13:35 +01:00
Sascha Ißbrücker
6d3755f46a Update version 2025-03-06 17:36:10 +01:00
Sascha Ißbrücker
25342e5fb6 Update version 2025-03-06 17:32:02 +01:00
Sascha Ißbrücker
be548a95a0 Update build job 2025-03-06 17:31:14 +01:00
Sascha Ißbrücker
978fba4cf5 Update build job 2025-03-06 09:48:04 +01:00
Sascha Ißbrücker
8a3572ba4b Add bookmark assets API (#1003)
* Add list, details and download endpoints

* Avoid using multiple DefaultRoute instances

* Add upload endpoint

* Add docs

* Allow configuring max request content length

* Add option for disabling uploads

* Remove gzip field

* Add delete endpoint
2025-03-06 09:09:53 +01:00
Sascha Ißbrücker
b21812c30a Update build job 2025-03-06 09:09:16 +01:00
Nick Sartor
72fbf6a590 Add linklater to community section (#1002)
Adding android client linklater to community projects md. Fixes #741
2025-03-04 11:33:56 +01:00
jvt
31ac796d6d Add Telegram bot to community section (#1001)
add my telegram bot project to send bookmarks directly to Linkding.
2025-03-04 11:31:45 +01:00
Sascha Ißbrücker
2d81ea6f6e Add REST endpoint for uploading snapshots from the Singlefile extension (#996)
* Extract asset logic

* Allow disabling HTML snapshot when creating bookmark

* Add endpoint for uploading singlefile snapshots

* Add URL parameter to disable HTML snapshots

* Allow using asset list in base Docker image

* Expose app version through profile
2025-02-23 22:58:14 +01:00
Sascha Ißbrücker
2e97b13bad Allow providing REST API authentication token with Bearer keyword (#995) 2025-02-22 19:59:53 +01:00
Sascha Ißbrücker
30f85103cd Update CHANGELOG.md 2025-02-22 19:51:00 +01:00
Sascha Ißbrücker
cfe4ff113d Bump version 2025-02-22 19:28:47 +01:00
Sascha Ißbrücker
757dc56277 Bump base images 2025-02-19 16:14:34 +01:00
Sascha Ißbrücker
dfbb367857 Fix auth proxy logout (#994) 2025-02-19 07:27:04 +01:00
Sascha Ißbrücker
2276832465 Return web archive fallback URL from REST API (#993) 2025-02-19 06:44:21 +01:00
Chris M
9d61bdce52 Add note about OIDC and LD_SUPERUSER_NAME combination (#992)
* docs: add note about OIDC and LD_SUPERUSER_NAME combination

Resolves #988

* tweak text

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-02-18 22:45:26 +01:00
Sascha Ißbrücker
1274a9ae0a Try limit uwsgi memory usage by configuring file descriptor limit (#990) 2025-02-15 08:49:58 +01:00
Sascha Ißbrücker
5e7172d17e Remove preview image when bookmark is deleted (#989) 2025-02-15 08:26:58 +01:00
Sascha Ißbrücker
78608135d9 Update CHANGELOG.md 2025-02-09 10:47:02 +01:00
Sascha Ißbrücker
51acd1da3f add build script 2025-02-08 18:20:15 +01:00
Sascha Ißbrücker
016ff2da66 Bump version 2025-02-08 10:54:46 +01:00
Sascha Ißbrücker
77d7e6e66a Add RSS link to shared bookmarks page (#984) 2025-02-08 10:51:17 +01:00
Sascha Ißbrücker
c5a300a435 Convert tag modal into drawer (#977)
* change tag modal into drawer

* improve scroll handling

* teleport all side bar content

* improve naming

* fix focus trap in filter drawer
2025-02-02 20:42:28 +01:00
Sascha Ißbrücker
0d4c47eb81 Add option to collapse side panel (#975) 2025-02-02 11:28:35 +01:00
Sascha Ißbrücker
17442eeb9a Improve accessibility of modal dialogs (#974)
* improve details modal accessibility

* improve tag modal accessibility

* fix overlays in archive and shared pages

* update tests

* use buttons for closing dialogs

* replace description list

* hide preview image from screen readers

* update tests
2025-02-02 00:28:17 +01:00
Kyuuk
2973812626 Allow customizing username when creating user through OIDC (#971)
* add ability to cutomize claim user for username generation on oidc login

* update documentation with new OIDC options

* oidc: also normalize custom claim as username

* improve tests

* improve docs

* some more cleanup

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-01-30 03:40:52 +01:00
Josh Dick
fc48b266a8 Add Additional iOS Shortcut to community section (#968) 2025-01-28 13:29:44 +01:00
Sascha Ißbrücker
7b42241026 Fix nav menu closing on mousedown in Safari (#965) 2025-01-27 09:05:19 +01:00
Sascha Ißbrücker
9c648dc67f Update CHANGELOG.md 2025-01-27 00:02:14 +01:00
Sascha Ißbrücker
1624128132 Bump versions to make Docker build 2025-01-26 23:52:36 +01:00
Sascha Ißbrücker
d1dd85538b Bump version 2025-01-26 19:19:48 +01:00
dependabot[bot]
c5aab3886e Bump nanoid from 3.3.7 to 3.3.8 in /docs (#962)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 19:09:02 +01:00
dependabot[bot]
3f2739e5a6 Bump astro from 4.16.3 to 4.16.18 in /docs (#929)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.16.3 to 4.16.18.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/astro@4.16.18/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@4.16.18/packages/astro)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 19:08:50 +01:00
dependabot[bot]
f1ed89a0ba Bump nanoid from 3.3.7 to 3.3.8 (#928)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:51:40 +01:00
dependabot[bot]
a59a7a777c Bump django from 5.1.1 to 5.1.5 (#947)
Bumps [django](https://github.com/django/django) from 5.1.1 to 5.1.5.
- [Commits](https://github.com/django/django/compare/5.1.1...5.1.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:50:51 +01:00
dependabot[bot]
9a5c535872 Bump vite from 5.4.9 to 5.4.14 in /docs (#953)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.9 to 5.4.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:50:34 +01:00
Sascha Ißbrücker
e6ebca1436 Add default robots.txt to block crawlers (#959) 2025-01-26 09:58:58 +01:00
Justus Grunow
085d67e9f4 Update community.md (#897)
Added link to iOS App Store for Linkdy.

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-01-15 22:10:16 +01:00
Rostislav
68825444fb Add a rust client library to community.md (#914)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-01-15 22:04:28 +01:00
Sebastien Wains
b2ca16ec9c Update community.md (#949) 2025-01-15 22:02:23 +01:00
Sascha Ißbrücker
649f4154e5 Provide accessible name to radio groups (#945) 2025-01-12 22:10:14 +01:00
Sascha Ißbrücker
d2e8a95e3c Fix menu dropdown focus traps (#944) 2025-01-11 12:44:20 +01:00
Sascha Ißbrücker
c3149409b0 Fix how to for changing font size 2024-10-24 22:24:58 +02:00
Vadim Khitrin
4626fa1c67 docs: Add cosmicding To Community Resources (#892)
* docs: Add cosmicding To Community Resources

* sort alphabetically

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-10-24 22:16:22 +02:00
Dany Marcoux
6548e16baa Add option to disable request logs (#887)
Closes #785
2024-10-17 21:47:56 +02:00
dependabot[bot]
c177de164a Bump astro from 4.15.8 to 4.16.3 in /docs (#884)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.15.8 to 4.16.3.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@4.16.3/packages/astro)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-16 21:08:46 +02:00
Xuanli
e9ecad38ac Add serchding to community projects (#880) 2024-10-12 18:13:50 +02:00
Sascha Ißbrücker
621aedd8eb Update donations 2024-10-04 18:43:02 +02:00
Sascha Ißbrücker
4187141ac8 Update CHANGELOG.md 2024-10-03 00:09:51 +02:00
91 changed files with 3392 additions and 1323 deletions

74
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: build
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Read version from file
id: get_version
run: echo "VERSION=$(cat version.txt)" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build latest
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest
sissbruecker/linkding:${{ env.VERSION }}
target: linkding
push: true
- name: Build latest-alpine
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-alpine
sissbruecker/linkding:${{ env.VERSION }}-alpine
target: linkding
push: true
- name: Build latest-plus
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-plus
sissbruecker/linkding:${{ env.VERSION }}-plus
target: linkding-plus
push: true
- name: Build latest-plus-alpine
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-plus-alpine
sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
target: linkding-plus
push: true

View File

@@ -1,6 +1,95 @@
# Changelog
## (23/09/2024)
## v1.38.1 (22/02/2025)
### What's Changed
* Remove preview image when bookmark is deleted by @sissbruecker in https://github.com/sissbruecker/linkding/pull/989
* Try limit uwsgi memory usage by configuring file descriptor limit by @sissbruecker in https://github.com/sissbruecker/linkding/pull/990
* Add note about OIDC and LD_SUPERUSER_NAME combination by @tebriel in https://github.com/sissbruecker/linkding/pull/992
* Return web archive fallback URL from REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/993
* Fix auth proxy logout by @sissbruecker in https://github.com/sissbruecker/linkding/pull/994
### New Contributors
* @tebriel made their first contribution in https://github.com/sissbruecker/linkding/pull/992
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.0...v1.38.1
---
## v1.38.0 (09/02/2025)
### What's Changed
* Fix nav menu closing on mousedown in Safari by @sissbruecker in https://github.com/sissbruecker/linkding/pull/965
* Allow customizing username when creating user through OIDC by @kyuuk in https://github.com/sissbruecker/linkding/pull/971
* Improve accessibility of modal dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/974
* Add option to collapse side panel by @sissbruecker in https://github.com/sissbruecker/linkding/pull/975
* Convert tag modal into drawer by @sissbruecker in https://github.com/sissbruecker/linkding/pull/977
* Add RSS link to shared bookmarks page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/984
* Add Additional iOS Shortcut to community section by @joshdick in https://github.com/sissbruecker/linkding/pull/968
### New Contributors
* @kyuuk made their first contribution in https://github.com/sissbruecker/linkding/pull/971
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.37.0...v1.38.0
---
## v1.37.0 (26/01/2025)
### What's Changed
* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887
* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959
* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944
* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945
* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880
* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892
* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949
* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914
* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897
* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884
* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953
* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947
* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928
* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929
* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962
### New Contributors
* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880
* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887
* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892
* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949
* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0
---
## v1.36.0 (02/10/2024)
### What's Changed
* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866
* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860
* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849
* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850
* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852
* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853
* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854
* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858
* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863
* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865
* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855
* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851
* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856
### New Contributors
* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850
* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0
---
## v1.35.0 (23/09/2024)
### What's Changed
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835

34
bookmarks/api/auth.py Normal file
View File

@@ -0,0 +1,34 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication, get_authorization_header
class LinkdingTokenAuthentication(TokenAuthentication):
"""
Extends DRF TokenAuthentication to add support for multiple keywords
"""
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
def authenticate(self, request):
auth = get_authorization_header(request).split()
if not auth or auth[0].lower() not in self.keywords:
return None
if len(auth) == 1:
msg = _("Invalid token header. No credentials provided.")
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _("Invalid token header. Token string should not contain spaces.")
raise exceptions.AuthenticationFailed(msg)
try:
token = auth[1].decode()
except UnicodeError:
msg = _(
"Invalid token header. Token string should not contain invalid characters."
)
raise exceptions.AuthenticationFailed(msg)
return self.authenticate_credentials(token)

View File

@@ -1,25 +1,24 @@
import gzip
import logging
import os
from django.conf import settings
from django.http import FileResponse, Http404
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.routers import SimpleRouter, DefaultRouter
from bookmarks import queries
from bookmarks.api.serializers import (
BookmarkSerializer,
BookmarkAssetSerializer,
TagSerializer,
UserProfileSerializer,
)
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
from bookmarks.services import auto_tagging
from bookmarks.services.bookmarks import (
archive_bookmark,
unarchive_bookmark,
website_loader,
)
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
logger = logging.getLogger(__name__)
@@ -57,10 +56,12 @@ class BookmarkViewSet(
def get_serializer_context(self):
disable_scraping = "disable_scraping" in self.request.GET
disable_html_snapshot = "disable_html_snapshot" in self.request.GET
return {
"request": self.request,
"user": self.request.user,
"disable_scraping": disable_scraping,
"disable_html_snapshot": disable_html_snapshot,
}
@action(methods=["get"], detail=False)
@@ -89,13 +90,13 @@ class BookmarkViewSet(
@action(methods=["post"], detail=True)
def archive(self, request, pk):
bookmark = self.get_object()
archive_bookmark(bookmark)
bookmarks.archive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["post"], detail=True)
def unarchive(self, request, pk):
bookmark = self.get_object()
unarchive_bookmark(bookmark)
bookmarks.unarchive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["get"], detail=False)
@@ -129,6 +130,118 @@ class BookmarkViewSet(
status=status.HTTP_200_OK,
)
@action(methods=["post"], detail=False)
def singlefile(self, request):
if settings.LD_DISABLE_ASSET_UPLOAD:
return Response(
{"error": "Asset upload is disabled."},
status=status.HTTP_403_FORBIDDEN,
)
url = request.data.get("url")
file = request.FILES.get("file")
if not url or not file:
return Response(
{"error": "Both 'url' and 'file' parameters are required."},
status=status.HTTP_400_BAD_REQUEST,
)
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
if not bookmark:
bookmark = Bookmark(url=url)
bookmark = bookmarks.create_bookmark(
bookmark, "", request.user, disable_html_snapshot=True
)
bookmarks.enhance_with_website_metadata(bookmark)
assets.upload_snapshot(bookmark, file.read())
return Response(
{"message": "Snapshot uploaded successfully."},
status=status.HTTP_201_CREATED,
)
class BookmarkAssetViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
):
serializer_class = BookmarkAssetSerializer
def get_queryset(self):
user = self.request.user
bookmark_id = self.kwargs["bookmark_id"]
if not Bookmark.objects.filter(id=bookmark_id, owner=user).exists():
raise Http404("Bookmark does not exist")
return BookmarkAsset.objects.filter(
bookmark_id=bookmark_id, bookmark__owner=user
)
def get_serializer_context(self):
return {"user": self.request.user}
@action(detail=True, methods=["get"], url_path="download")
def download(self, request, bookmark_id, pk):
asset = self.get_object()
try:
file_path = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
content_type = asset.content_type
file_stream = (
gzip.GzipFile(file_path, mode="rb")
if asset.gzip
else open(file_path, "rb")
)
file_name = (
f"{asset.display_name}.html"
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else asset.display_name
)
response = FileResponse(file_stream, content_type=content_type)
response["Content-Disposition"] = f'attachment; filename="{file_name}"'
return response
except FileNotFoundError:
raise Http404("Asset file does not exist")
except Exception as e:
logger.error(
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
exc_info=e,
)
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(methods=["post"], detail=False)
def upload(self, request, bookmark_id):
if settings.LD_DISABLE_ASSET_UPLOAD:
return Response(
{"error": "Asset upload is disabled."},
status=status.HTTP_403_FORBIDDEN,
)
bookmark = Bookmark.objects.filter(id=bookmark_id, owner=request.user).first()
if not bookmark:
raise Http404("Bookmark does not exist")
upload_file = request.FILES.get("file")
if not upload_file:
return Response(
{"error": "No file provided."}, status=status.HTTP_400_BAD_REQUEST
)
try:
asset = assets.upload_asset(bookmark, upload_file)
serializer = self.get_serializer(asset)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark_id={bookmark_id}, file={upload_file.name}",
exc_info=e,
)
return Response(
{"error": "Failed to upload asset."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class TagViewSet(
viewsets.GenericViewSet,
@@ -152,7 +265,19 @@ class UserViewSet(viewsets.GenericViewSet):
return Response(UserProfileSerializer(request.user.profile).data)
router = DefaultRouter()
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
router.register(r"tags", TagViewSet, basename="tag")
router.register(r"user", UserViewSet, basename="user")
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
# Instead create separate routers for each view set and manually register them in urls.py
# The default router is only used to allow reversing a URL for the API root
default_router = DefaultRouter()
bookmark_router = SimpleRouter()
bookmark_router.register("", BookmarkViewSet, basename="bookmark")
tag_router = SimpleRouter()
tag_router.register("", TagViewSet, basename="tag")
user_router = SimpleRouter()
user_router.register("", UserViewSet, basename="user")
bookmark_asset_router = SimpleRouter()
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")

View File

@@ -3,13 +3,11 @@ from django.templatetags.static import static
from rest_framework import serializers
from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
from bookmarks.services.bookmarks import (
create_bookmark,
update_bookmark,
enhance_with_website_metadata,
)
from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
from bookmarks.services import bookmarks
from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.utils import app_version
class TagListField(serializers.ListField):
@@ -59,9 +57,10 @@ class BookmarkSerializer(serializers.ModelSerializer):
# Custom tag_names field to allow passing a list of tag names to create/update
tag_names = TagListField(required=False)
# Custom fields to return URLs for favicon and preview image
# Custom fields to generate URLs for favicon, preview image, and web archive snapshot
favicon_url = serializers.SerializerMethodField()
preview_image_url = serializers.SerializerMethodField()
web_archive_snapshot_url = serializers.SerializerMethodField()
# Add dummy website title and description fields for backwards compatibility but keep them empty
website_title = serializers.SerializerMethodField()
website_description = serializers.SerializerMethodField()
@@ -82,6 +81,12 @@ class BookmarkSerializer(serializers.ModelSerializer):
preview_image_url = request.build_absolute_uri(preview_image_file_path)
return preview_image_url
def get_web_archive_snapshot_url(self, obj: Bookmark):
if obj.web_archive_snapshot_url:
return obj.web_archive_snapshot_url
return generate_fallback_webarchive_url(obj.url, obj.date_added)
def get_website_title(self, obj: Bookmark):
return None
@@ -93,12 +98,20 @@ class BookmarkSerializer(serializers.ModelSerializer):
tag_string = build_tag_string(tag_names)
bookmark = Bookmark(**validated_data)
saved_bookmark = create_bookmark(bookmark, tag_string, self.context["user"])
disable_scraping = self.context.get("disable_scraping", False)
disable_html_snapshot = self.context.get("disable_html_snapshot", False)
saved_bookmark = bookmarks.create_bookmark(
bookmark,
tag_string,
self.context["user"],
disable_html_snapshot=disable_html_snapshot,
)
# Unless scraping is explicitly disabled, enhance bookmark with website
# metadata to preserve backwards compatibility with clients that expect
# title and description to be populated automatically when left empty
if not self.context.get("disable_scraping", False):
enhance_with_website_metadata(saved_bookmark)
if not disable_scraping:
bookmarks.enhance_with_website_metadata(saved_bookmark)
return saved_bookmark
def update(self, instance: Bookmark, validated_data):
@@ -109,7 +122,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
if not field.read_only and field_name in validated_data:
setattr(instance, field_name, validated_data[field_name])
return update_bookmark(instance, tag_string, self.context["user"])
return bookmarks.update_bookmark(instance, tag_string, self.context["user"])
def validate(self, attrs):
# When creating a bookmark, the service logic prevents duplicate URLs by
@@ -130,6 +143,21 @@ class BookmarkSerializer(serializers.ModelSerializer):
return attrs
class BookmarkAssetSerializer(serializers.ModelSerializer):
class Meta:
model = BookmarkAsset
fields = [
"id",
"bookmark",
"date_created",
"file_size",
"asset_type",
"content_type",
"display_name",
"status",
]
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
@@ -155,4 +183,11 @@ class UserProfileSerializer(serializers.ModelSerializer):
"display_url",
"permanent_notes",
"search_preferences",
"version",
]
read_only_fields = ["version"]
version = serializers.SerializerMethodField()
def get_version(self, obj: UserProfile):
return app_version

View File

@@ -0,0 +1,48 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
def setUp(self) -> None:
super().setUp()
def assertSidePanelIsVisible(self):
expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible()
expect(
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
).not_to_be_visible()
def assertSidePanelIsHidden(self):
expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible()
expect(
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
).to_be_visible()
def test_side_panel_should_be_visible_by_default(self):
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
self.assertSidePanelIsVisible()
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
self.assertSidePanelIsVisible()
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
self.assertSidePanelIsVisible()
def test_side_panel_should_be_hidden_when_collapsed(self):
user = self.get_or_create_test_user()
user.profile.collapse_side_panel = True
user.profile.save()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
self.assertSidePanelIsHidden()
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
self.assertSidePanelIsHidden()
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
self.assertSidePanelIsHidden()

View File

@@ -4,7 +4,7 @@ from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
def test_show_modal_close_modal(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
@@ -12,31 +12,31 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p)
# use smaller viewport to make tags button visible
# use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal
modal_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Tags"
# open drawer
drawer_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Filters"
)
modal_trigger.click()
drawer_trigger.click()
# verify modal is visible
modal = page.locator(".modal")
expect(modal).to_be_visible()
expect(modal.locator("h2")).to_have_text("Tags")
# verify drawer is visible
drawer = page.locator(".modal.drawer.filter-drawer")
expect(drawer).to_be_visible()
expect(drawer.locator("h2")).to_have_text("Filters")
# close with close button
modal.locator("button.close").click()
expect(modal).to_be_hidden()
drawer.locator("button.close").click()
expect(drawer).to_be_hidden()
# open modal again
modal_trigger.click()
# open drawer again
drawer_trigger.click()
# close with backdrop
backdrop = modal.locator(".modal-overlay")
backdrop = drawer.locator(".modal-overlay")
backdrop.click(position={"x": 0, "y": 0})
expect(modal).to_be_hidden()
expect(drawer).to_be_hidden()
def test_select_tag(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
@@ -45,29 +45,29 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p)
# use smaller viewport to make tags button visible
# use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal
modal_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Tags"
drawer_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Filters"
)
modal_trigger.click()
drawer_trigger.click()
# verify tags are displayed
modal = page.locator(".modal")
unselected_tags = modal.locator(".unselected-tags")
drawer = page.locator(".modal.drawer.filter-drawer")
unselected_tags = drawer.locator(".unselected-tags")
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
# select tag
unselected_tags.get_by_text("cooking").click()
# open modal again
modal_trigger.click()
# open drawer again
drawer_trigger.click()
# verify tag is selected, other tag is not visible anymore
selected_tags = modal.locator(".selected-tags")
selected_tags = drawer.locator(".selected-tags")
expect(selected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()

View File

@@ -1,61 +1,28 @@
import { Behavior, registerBehavior } from "./index";
import { registerBehavior } from "./index";
import { isKeyboardActive } from "./focus-utils";
import { ModalBehavior } from "./modal";
class DetailsModalBehavior extends Behavior {
constructor(element) {
super(element);
class DetailsModalBehavior extends ModalBehavior {
doClose() {
super.doClose();
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
// Navigate to close URL
const closeUrl = this.element.dataset.closeUrl;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
this.buttonLink = element.querySelector("a:has(button.close)");
// Try restore focus to view details to view details link of respective bookmark
const bookmarkId = this.element.dataset.bookmarkId;
const restoreFocusElement =
document.querySelector(
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
) ||
document.querySelector("ul.bookmark-list") ||
document.body;
this.overlayLink.addEventListener("click", this.onClose);
this.buttonLink.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.overlayLink.removeEventListener("click", this.onClose);
this.buttonLink.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.element.remove();
const closeUrl = this.overlayLink.href;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
}
},
{ once: true },
);
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}

View File

@@ -6,23 +6,38 @@ class DropdownBehavior extends Behavior {
this.opened = false;
this.onClick = this.onClick.bind(this);
this.onOutsideClick = this.onOutsideClick.bind(this);
this.onEscape = this.onEscape.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
// Prevent opening the dropdown automatically on focus, so that it only
// opens on click then JS is enabled
this.element.style.setProperty("--dropdown-focus-display", "none");
this.element.addEventListener("keydown", this.onEscape);
this.element.addEventListener("focusout", this.onFocusOut);
this.toggle = element.querySelector(".dropdown-toggle");
this.toggle.setAttribute("aria-expanded", "false");
this.toggle.addEventListener("click", this.onClick);
}
destroy() {
this.close();
this.toggle.removeEventListener("click", this.onClick);
this.element.removeEventListener("keydown", this.onEscape);
this.element.removeEventListener("focusout", this.onFocusOut);
}
open() {
this.opened = true;
this.element.classList.add("active");
this.toggle.setAttribute("aria-expanded", "true");
document.addEventListener("click", this.onOutsideClick);
}
close() {
this.opened = false;
this.element.classList.remove("active");
this.toggle.setAttribute("aria-expanded", "false");
document.removeEventListener("click", this.onOutsideClick);
}
@@ -39,6 +54,20 @@ class DropdownBehavior extends Behavior {
this.close();
}
}
onEscape(event) {
if (event.key === "Escape" && this.opened) {
event.preventDefault();
this.close();
this.toggle.focus();
}
}
onFocusOut(event) {
if (!this.element.contains(event.relatedTarget)) {
this.close();
}
}
}
registerBehavior("ld-dropdown", DropdownBehavior);

View File

@@ -0,0 +1,97 @@
import { Behavior, registerBehavior } from "./index";
import { ModalBehavior } from "./modal";
import { isKeyboardActive } from "./focus-utils";
class FilterDrawerTriggerBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "drawer", "filter-drawer");
modal.setAttribute("ld-filter-drawer", "");
modal.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Filters</h2>
<button class="close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<section class="content content-area"></div>
</div>
</div>
`;
document.body.querySelector(".modals").appendChild(modal);
}
}
class FilterDrawerBehavior extends ModalBehavior {
init() {
// Teleport content before creating focus trap, otherwise it will not detect
// focusable content elements
this.teleport();
super.init();
// Add active class to start slide-in animation
this.element.classList.add("active");
}
destroy() {
super.destroy();
// Always close on destroy to restore drawer content to original location
// before turbo caches DOM
this.doClose();
}
mapHeading(container, from, to) {
const headings = container.querySelectorAll(from);
headings.forEach((heading) => {
const newHeading = document.createElement(to);
newHeading.textContent = heading.textContent;
heading.replaceWith(newHeading);
});
}
teleport() {
const content = this.element.querySelector(".content");
const sidePanel = document.querySelector("section.side-panel");
content.append(...sidePanel.children);
this.mapHeading(content, "h2", "h3");
}
teleportBack() {
const sidePanel = document.querySelector("section.side-panel");
const content = this.element.querySelector(".content");
sidePanel.append(...content.children);
this.mapHeading(sidePanel, "h3", "h2");
}
doClose() {
super.doClose();
this.teleportBack();
// Try restore focus to drawer trigger
const restoreFocusElement =
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);

View File

@@ -0,0 +1,59 @@
let keyboardActive = false;
window.addEventListener(
"keydown",
() => {
keyboardActive = true;
},
{ capture: true },
);
window.addEventListener(
"mousedown",
() => {
keyboardActive = false;
},
{ capture: true },
);
export function isKeyboardActive() {
return keyboardActive;
}
export class FocusTrapController {
constructor(element) {
this.element = element;
this.focusableElements = this.element.querySelectorAll(
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
);
this.firstFocusableElement = this.focusableElements[0];
this.lastFocusableElement =
this.focusableElements[this.focusableElements.length - 1];
this.onKeyDown = this.onKeyDown.bind(this);
this.firstFocusableElement.focus({ focusVisible: keyboardActive });
this.element.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.element.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
if (event.key !== "Tab") {
return;
}
if (event.shiftKey) {
if (document.activeElement === this.firstFocusableElement) {
event.preventDefault();
this.lastFocusableElement.focus();
}
} else {
if (document.activeElement === this.lastFocusableElement) {
event.preventDefault();
this.firstFocusableElement.focus();
}
}
}
}

View File

@@ -0,0 +1,91 @@
import { Behavior } from "./index";
import { FocusTrapController } from "./focus-utils";
export class ModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.overlay = element.querySelector(".modal-overlay");
this.closeButton = element.querySelector(".modal-header .close");
this.overlay.addEventListener("click", this.onClose);
this.closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
this.init();
}
destroy() {
this.overlay.removeEventListener("click", this.onClose);
this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
this.clearInert();
this.focusTrap.destroy();
}
init() {
this.setupInert();
this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"),
);
}
setupInert() {
// Inert all other elements on the page
document
.querySelectorAll("body > *:not(.modals)")
.forEach((el) => el.setAttribute("inert", ""));
// Lock scroll on the body
document.body.classList.add("scroll-lock");
}
clearInert() {
// Clear inert attribute from all elements to allow focus outside the modal again
document
.querySelectorAll("body > *")
.forEach((el) => el.removeAttribute("inert"));
// Remove scroll lock from the body
document.body.classList.remove("scroll-lock");
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.doClose();
}
},
{ once: true },
);
}
doClose() {
this.element.remove();
this.clearInert();
this.element.dispatchEvent(new CustomEvent("modal:close"));
}
}

View File

@@ -1,68 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class TagModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
this.onClose = this.onClose.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.onClose();
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header">
<h2>Tags</h2>
<button class="close" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
const tagCloud = document.querySelector(".tag-cloud");
const tagCloudContainer = tagCloud.parentElement;
const content = modal.querySelector(".content");
content.appendChild(tagCloud);
const overlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector(".close");
overlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
this.modal = modal;
this.tagCloud = tagCloud;
this.tagCloudContainer = tagCloudContainer;
document.body.appendChild(modal);
}
onClose() {
if (!this.modal) {
return;
}
this.modal.remove();
this.tagCloudContainer.appendChild(this.tagCloud);
}
}
registerBehavior("ld-tag-modal", TagModalBehavior);

View File

@@ -3,13 +3,13 @@ import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/clear-button";
import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/form";
import "./behaviors/details-modal";
import "./behaviors/dropdown";
import "./behaviors/filter-drawer";
import "./behaviors/form";
import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete";
import "./behaviors/tag-modal";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-02 09:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0042_userprofile_custom_css_hash"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="collapse_side_panel",
field=models.BooleanField(default=False),
),
]

View File

@@ -93,6 +93,19 @@ class Bookmark(models.Model):
return self.resolved_title + " (" + self.url[:30] + "...)"
@receiver(post_delete, sender=Bookmark)
def bookmark_deleted(sender, instance, **kwargs):
if instance.preview_image_file:
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, instance.preview_image_file)
if os.path.isfile(filepath):
try:
os.remove(filepath)
except Exception as error:
logger.error(
f"Failed to delete preview image: {filepath}", exc_info=error
)
class BookmarkAsset(models.Model):
TYPE_SNAPSHOT = "snapshot"
TYPE_UPLOAD = "upload"
@@ -440,6 +453,7 @@ class UserProfile(models.Model):
null=False, default=30, validators=[MinValueValidator(10)]
)
sticky_pagination = models.BooleanField(default=False, null=False)
collapse_side_panel = models.BooleanField(default=False, null=False)
def save(self, *args, **kwargs):
if self.custom_css:
@@ -479,6 +493,7 @@ class UserProfileForm(forms.ModelForm):
"auto_tagging_rules",
"items_per_page",
"sticky_pagination",
"collapse_side_panel",
]

View File

@@ -0,0 +1,128 @@
import gzip
import logging
import os
import shutil
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone, formats
from bookmarks.models import Bookmark, BookmarkAsset
from bookmarks.services import singlefile
MAX_ASSET_FILENAME_LENGTH = 192
logger = logging.getLogger(__name__)
def create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
date_created = timezone.now()
timestamp = formats.date_format(date_created, "SHORT_DATE_FORMAT")
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
date_created=date_created,
content_type=BookmarkAsset.CONTENT_TYPE_HTML,
display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING,
)
return asset
def create_snapshot(asset: BookmarkAsset):
try:
# Create snapshot into temporary file
temp_filename = _generate_asset_filename(asset, asset.bookmark.url, "tmp")
temp_filepath = os.path.join(settings.LD_ASSET_FOLDER, temp_filename)
singlefile.create_snapshot(asset.bookmark.url, temp_filepath)
# Store as gzip in asset folder
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(temp_filepath, "rb") as temp_file, gzip.open(
filepath, "wb"
) as gz_file:
shutil.copyfileobj(temp_file, gz_file)
# Remove temporary file
os.remove(temp_filepath)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
except Exception as error:
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
raise error
def upload_snapshot(bookmark: Bookmark, html: bytes):
asset = create_snapshot_asset(bookmark)
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with gzip.open(filepath, "wb") as gz_file:
gz_file.write(html)
# Only save the asset if the file was written successfully
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
return asset
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
try:
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
date_created=timezone.now(),
content_type=upload_file.content_type,
display_name=upload_file.name,
status=BookmarkAsset.STATUS_COMPLETE,
gzip=False,
)
name, extension = os.path.splitext(upload_file.name)
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.file = filename
asset.file_size = upload_file.size
asset.save()
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
return asset
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
exc_info=e,
)
raise e
def _generate_asset_filename(
asset: BookmarkAsset, filename: str, extension: str
) -> str:
def sanitize_char(char):
if char.isalnum() or char in ("-", "_", "."):
return char
else:
return "_"
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
sanitized_filename = "".join(sanitize_char(char) for char in filename)
# Calculate the length of fixed parts of the final filename
non_filename_length = len(f"{asset.asset_type}_{formatted_datetime}_.{extension}")
# Calculate the maximum length for the dynamic part of the filename
max_filename_length = MAX_ASSET_FILENAME_LENGTH - non_filename_length
# Truncate the filename if necessary
sanitized_filename = sanitized_filename[:max_filename_length]
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_filename}.{extension}"

View File

@@ -1,22 +1,24 @@
import logging
import os
from typing import Union
from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
from bookmarks.models import Bookmark, parse_tag_string
from bookmarks.services import auto_tagging
from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services import auto_tagging
from bookmarks.services.tags import get_or_create_tags
logger = logging.getLogger(__name__)
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
def create_bookmark(
bookmark: Bookmark,
tag_string: str,
current_user: User,
disable_html_snapshot: bool = False,
):
# If URL is already bookmarked, then update it
existing_bookmark: Bookmark = Bookmark.objects.filter(
owner=current_user, url=bookmark.url
@@ -42,7 +44,10 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
# Load preview image
tasks.load_preview_image(current_user, bookmark)
# Create HTML snapshot
if current_user.profile.enable_automatic_html_snapshots:
if (
current_user.profile.enable_automatic_html_snapshots
and not disable_html_snapshot
):
tasks.create_html_snapshot(bookmark)
return bookmark
@@ -193,46 +198,6 @@ def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
)
def _generate_upload_asset_filename(asset: BookmarkAsset, filename: str):
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
return f"{asset.asset_type}_{formatted_datetime}_{filename}"
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile) -> BookmarkAsset:
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
content_type=upload_file.content_type,
display_name=upload_file.name,
status=BookmarkAsset.STATUS_PENDING,
gzip=False,
)
asset.save()
try:
filename = _generate_upload_asset_filename(asset, upload_file.name)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.file_size = upload_file.size
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
exc_info=e,
)
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
return asset
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description

View File

@@ -1,8 +1,6 @@
import gzip
import logging
import os
import shlex
import shutil
import signal
import subprocess
@@ -18,27 +16,20 @@ logger = logging.getLogger(__name__)
def create_snapshot(url: str, filepath: str):
singlefile_path = settings.LD_SINGLEFILE_PATH
# parse options to list of arguments
ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)
custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)
temp_filepath = filepath + ".tmp"
# concat lists
args = [singlefile_path] + ublock_options + custom_options + [url, temp_filepath]
args = [singlefile_path] + ublock_options + custom_options + [url, filepath]
try:
# Use start_new_session=True to create a new process group
process = subprocess.Popen(args, start_new_session=True)
process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC)
# check if the file was created
if not os.path.exists(temp_filepath):
if not os.path.exists(filepath):
raise SingleFileError("Failed to create snapshot")
with open(temp_filepath, "rb") as raw_file, gzip.open(
filepath, "wb"
) as gz_file:
shutil.copyfileobj(raw_file, gz_file)
os.remove(temp_filepath)
except subprocess.TimeoutExpired:
# First try to terminate properly
try:

View File

@@ -1,6 +1,5 @@
import functools
import logging
import os
from typing import List
import waybackpy
@@ -8,14 +7,13 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils import timezone, formats
from huey import crontab
from huey.contrib.djhuey import HUEY as huey
from huey.exceptions import TaskLockedException
from waybackpy.exceptions import WaybackError, TooManyRequestsError
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
from bookmarks.services import assets, favicon_loader, preview_image_loader
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
logger = logging.getLogger(__name__)
@@ -236,7 +234,7 @@ def create_html_snapshot(bookmark: Bookmark):
if not is_html_snapshot_feature_active():
return
asset = _create_snapshot_asset(bookmark)
asset = assets.create_snapshot_asset(bookmark)
asset.save()
@@ -246,47 +244,12 @@ def create_html_snapshots(bookmark_list: List[Bookmark]):
assets_to_create = []
for bookmark in bookmark_list:
asset = _create_snapshot_asset(bookmark)
asset = assets.create_snapshot_asset(bookmark)
assets_to_create.append(asset)
BookmarkAsset.objects.bulk_create(assets_to_create)
MAX_SNAPSHOT_FILENAME_LENGTH = 192
def _create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
content_type="text/html",
display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING,
)
return asset
def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
def sanitize_char(char):
if char.isalnum() or char in ("-", "_", "."):
return char
else:
return "_"
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
sanitized_url = "".join(sanitize_char(char) for char in asset.bookmark.url)
# Calculate the length of the non-URL parts of the filename
non_url_length = len(f"{asset.asset_type}{formatted_datetime}__.html.gz")
# Calculate the maximum length for the URL part
max_url_length = MAX_SNAPSHOT_FILENAME_LENGTH - non_url_length
# Truncate the URL if necessary
sanitized_url = sanitized_url[:max_url_length]
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz"
# singe-file does not support running multiple instances in parallel, so we can
# not queue up multiple snapshot tasks at once. Instead, schedule a periodic
# task that grabs a number of pending assets and creates snapshots for them in
@@ -313,13 +276,8 @@ def _create_html_snapshot_task(asset_id: int):
logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}")
try:
filename = _generate_snapshot_filename(asset)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
singlefile.create_snapshot(asset.bookmark.url, filepath)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
assets.create_snapshot(asset)
logger.info(
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
)
@@ -328,8 +286,6 @@ def _create_html_snapshot_task(asset_id: int):
f"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}",
exc_info=error,
)
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
def create_missing_html_snapshots(user: User) -> int:

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -36,8 +36,14 @@
}
}
& dl {
margin-bottom: 0;
& .sections section {
margin-top: var(--unit-4);
}
& .sections h3 {
margin-bottom: var(--unit-2);
font-size: var(--font-size);
font-weight: bold;
}
& .assets {

View File

@@ -10,8 +10,38 @@
}
/* Bookmark page grid */
.bookmarks-page.grid {
grid-gap: var(--unit-9);
.bookmarks-page {
&.grid {
grid-gap: var(--unit-9);
}
[ld-filter-drawer-trigger] {
display: none;
}
@media (max-width: 840px) {
section.side-panel {
display: none;
}
[ld-filter-drawer-trigger] {
display: inline-block;
}
}
&.collapse-side-panel {
section.main {
grid-column: span var(--grid-columns);
}
section.side-panel {
display: none;
}
[ld-filter-drawer-trigger] {
display: inline-block;
}
}
}
/* Bookmark area header controls */

View File

@@ -2,7 +2,8 @@
/* Content area component */
section.content-area {
h2 {
h2,
h3 {
font-size: var(--font-size-lg);
}
@@ -14,7 +15,8 @@ section.content-area {
padding-bottom: var(--unit-2);
margin-bottom: var(--unit-4);
h2 {
h2,
h3 {
flex: 0 0 auto;
line-height: var(--unit-9);
margin: 0;

View File

@@ -10,7 +10,6 @@ html {
font-size: var(--html-font-size);
line-height: var(--html-line-height);
-webkit-tap-highlight-color: transparent;
scrollbar-gutter: stable;
}
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */

View File

@@ -1,5 +1,7 @@
/* Dropdown */
.dropdown {
--dropdown-focus-display: block;
display: inline-block;
position: relative;
@@ -20,9 +22,13 @@
}
}
&.active .menu,
.dropdown-toggle:focus + .menu,
.menu:hover {
&:focus-within .menu {
/* Use custom CSS property to allow disabling opening on focus when using JS */
display: var(--dropdown-focus-display);
}
&.active .menu {
/* Always show menu when class is added through JS */
display: block;
}

View File

@@ -62,13 +62,14 @@
gap: var(--unit-4);
max-height: 75vh;
max-width: var(--control-width-md);
padding: var(--unit-6);
width: 100%;
& .modal-header {
display: flex;
align-items: flex-start;
gap: var(--unit-2);
padding: var(--unit-6);
padding-bottom: 0;
color: var(--text-color);
& h2 {
@@ -78,7 +79,7 @@
margin: 0;
}
& button.close {
& .close {
background: none;
border: none;
padding: 0;
@@ -95,10 +96,53 @@
& .modal-body {
overflow-y: auto;
position: relative;
padding: 0 var(--unit-6);
}
& .modal-body:not(:has(+ .modal-footer)) {
margin-bottom: var(--unit-6);
}
& .modal-footer {
padding: var(--unit-6);
padding-top: 0;
text-align: right;
}
}
.modal.drawer {
display: block;
& .modal-container {
position: fixed;
top: 0;
right: 0;
width: 400px;
max-width: 100%;
height: 100%;
max-height: 100%;
border: none;
border-left: solid 1px var(--modal-container-border-color);
border-radius: 0;
transform: translateX(100%);
animation: fade-in 0.25s ease 1;
transition: transform 0.25s ease;
}
&.active {
& .modal-container {
transform: translateX(0);
}
}
&.active.closing {
& .modal-container {
animation: fade-out 0.25s ease 1;
transform: translateX(100%);
}
}
}
.scroll-lock {
overflow: hidden !important;
}

View File

@@ -4,17 +4,17 @@
{% load bookmarks %}
{% block content %}
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
<div ld-bulk-edit
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #}
<section class="content-area col-2">
<section class="main content-area col-2">
<div class="content-area-header mb-0">
<h2>Archived bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags
</button>
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div>
</div>
@@ -31,7 +31,7 @@
</section>
{# Tag cloud #}
<section class="content-area col-1 hide-md">
<section class="side-panel content-area col-1">
<div class="content-area-header">
<h2>Tags</h2>
</div>
@@ -39,12 +39,14 @@
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -6,10 +6,12 @@
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
role="list" tabindex="-1"
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
{% for bookmark_item in bookmark_list.items %}
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
<li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
{% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
<div class="content">
<div class="title">
<label class="form-checkbox bulk-edit-checkbox">
@@ -78,7 +80,8 @@
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
<a href="{{ bookmark_item.details_url }}" class="view-action"
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}

View File

@@ -33,12 +33,16 @@
{% if details.is_editable %}
<div class="assets-actions">
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
</button>
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
class="btn btn-sm">Upload file
</button>
{% if details.snapshots_enabled %}
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
</button>
{% endif %}
{% if details.uploads_enabled %}
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
class="btn btn-sm">Upload file
</button>
{% endif %}
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
</div>
{% endif %}

View File

@@ -40,14 +40,14 @@
</div>
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
<div class="preview-image">
<img src="{% static details.bookmark.preview_image_file %}"/>
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
</div>
{% endif %}
<dl class="grid columns-2 columns-sm-1 gap-0">
<div class="sections grid columns-2 columns-sm-1 gap-0">
{% if details.is_editable %}
<div class="status col-2">
<dt>Status</dt>
<dd class="d-flex" style="gap: .8rem">
<section class="status col-2">
<h3>Status</h3>
<div class="d-flex" style="gap: .8rem">
<div class="form-group">
<label class="form-switch">
<input ld-auto-submit type="checkbox" name="is_archived"
@@ -71,44 +71,42 @@
</label>
</div>
{% endif %}
</dd>
</div>
</div>
</section>
{% endif %}
{% if details.show_files %}
<div class="files col-2">
<dt>Files</dt>
<dd>
{% include 'bookmarks/details/assets.html' %}
</dd>
<section class="files col-2">
<h3>Files</h3>
<div>
{% include 'bookmarks/details/assets.html' %}
</div>
{% endif %}
</section>
{% if details.bookmark.tag_names %}
<div class="tags col-1">
<dt>Tags</dt>
<dd>
<section class="tags col-1">
<h3 id="details-modal-tags-title">Tags</h3>
<div>
{% for tag_name in details.bookmark.tag_names %}
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</dd>
</div>
</div>
</section>
{% endif %}
<div class="date-added col-1">
<dt>Date added</dt>
<dd>
<section class="date-added col-1">
<h3>Date added</h3>
<div>
<span>{{ details.bookmark.date_added }}</span>
</dd>
</div>
{% if details.bookmark.resolved_description %}
<div class="description col-2">
<dt>Description</dt>
<dd>{{ details.bookmark.resolved_description }}</dd>
</div>
</section>
{% if details.bookmark.resolved_description %}
<section class="description col-2">
<h3>Description</h3>
<div>{{ details.bookmark.resolved_description }}</div>
</section>
{% endif %}
{% if details.bookmark.notes %}
<div class="notes col-2">
<dt>Notes</dt>
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
</div>
<section class="notes col-2">
<h3>Notes</h3>
<div class="markdown">{% markdown details.bookmark.notes %}</div>
</section>
{% endif %}
</dl>
</div>
</form>

View File

@@ -1,21 +1,17 @@
<div class="modal active bookmark-details"
ld-details-modal>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
<div class="modal-overlay" aria-label="Close"></div>
</a>
<div class="modal-container">
<div class="modal active bookmark-details" ld-details-modal
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
<button class="close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</a>
<button class="close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content">

View File

@@ -36,5 +36,8 @@
{% if not request.global_settings.enable_link_prefetch %}
<meta name="turbo-prefetch" content="false">
{% endif %}
{% if rss_feed_url %}
<link rel="alternate" type="application/rss+xml" href="{{ rss_feed_url }}" />
{% endif %}
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</head>

View File

@@ -4,16 +4,17 @@
{% load bookmarks %}
{% block content %}
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
<div ld-bulk-edit
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #}
<section class="content-area col-2">
<section class="main content-area col-2">
<div class="content-area-header mb-0">
<h2>Bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags</button>
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div>
</div>
@@ -30,7 +31,7 @@
</section>
{# Tag cloud #}
<section class="content-area col-1 hide-md">
<section class="side-panel content-area col-1">
<div class="content-area-header">
<h2>Tags</h2>
</div>
@@ -38,12 +39,14 @@
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -97,5 +97,9 @@
{% block content %}
{% endblock %}
</div>
<div class="modals">
{% block overlays %}
{% endblock %}
</div>
</body>
</html>

View File

@@ -3,11 +3,11 @@
{# Basic menu list #}
<div class="hide-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<div class="dropdown">
<div ld-dropdown class="dropdown">
<button class="btn btn-link dropdown-toggle" tabindex="0">
Bookmarks
</button>
<ul class="menu">
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
</li>
@@ -28,28 +28,28 @@
</ul>
</div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<form class="d-inline" action="{% url 'logout' %}" method="post">
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
{% csrf_token %}
<button type="submit" class="btn btn-link">Logout</button>
</form>
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
<a href="{% url 'bookmarks:new' %}" aria-label="Add bookmark" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</a>
<div ld-dropdown class="dropdown dropdown-right">
<button class="btn btn-link dropdown-toggle" tabindex="0">
<button class="btn btn-link dropdown-toggle" aria-label="Navigation menu" tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<!-- menu component -->
<ul class="menu">
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
</li>
@@ -72,7 +72,7 @@
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
</li>
<li class="menu-item">
<form class="d-inline" action="{% url 'logout' %}" method="post">
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
{% csrf_token %}
<button type="submit" class="btn btn-link menu-link">Logout</button>
</form>

View File

@@ -15,7 +15,8 @@
{% endfor %}
</form>
<div ld-dropdown class="search-options dropdown dropdown-right">
<button type="button" class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<button type="button" aria-label="Search preferences"
class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -41,8 +42,11 @@
</div>
{% endif %}
{% if 'shared' in preferences_form.editable_fields %}
<div class="form-group radio-group">
<div class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">Shared filter</div>
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-shared-label">
<label id="search-shared-label"
class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">
Shared filter
</label>
{% for radio in preferences_form.shared %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}
@@ -53,8 +57,11 @@
</div>
{% endif %}
{% if 'unread' in preferences_form.editable_fields %}
<div class="form-group radio-group">
<div class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">Unread filter</div>
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-unread-label">
<label id="search-unread-label"
class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">
Unread filter
</label>
{% for radio in preferences_form.unread %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}

View File

@@ -4,16 +4,16 @@
{% load bookmarks %}
{% block content %}
<div class="bookmarks-page grid columns-md-1">
<div
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #}
<section class="content-area col-2">
<section class="main content-area col-2">
<div class="content-area-header">
<h2>Shared bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search mode='shared' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags
</button>
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div>
</div>
@@ -28,7 +28,7 @@
</section>
{# Filters #}
<section class="content-area col-1 hide-md">
<section class="side-panel content-area col-1">
<div class="content-area-header">
<h2>User</h2>
</div>
@@ -43,12 +43,14 @@
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -124,6 +124,16 @@
visible without having to scroll to the end of the page first.
</div>
</div>
<div class="form-group">
<label for="{{ form.collapse_side_panel.id_for_label }}" class="form-checkbox">
{{ form.collapse_side_panel }}
<i class="form-icon"></i> Collapse side panel
</label>
<div class="form-input-hint">
When enabled, the tags side panel will be collapsed by default to give more space to the bookmark list.
Instead, the tags are shown in an expandable drawer.
</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 width-25 width-sm-100" }}

View File

@@ -1,14 +1,21 @@
import random
import gzip
import logging
import os
import random
import shutil
import tempfile
from datetime import datetime
from typing import List
from unittest import TestCase
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import User
from django.test import override_settings
from django.utils import timezone
from django.utils.crypto import get_random_string
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, BookmarkAsset, Tag
@@ -17,6 +24,16 @@ from bookmarks.models import Bookmark, BookmarkAsset, Tag
class BookmarkFactoryMixin:
user = None
def setup_temp_assets_dir(self):
self.assets_dir = tempfile.mkdtemp()
self.settings_override = override_settings(LD_ASSET_FOLDER=self.assets_dir)
self.settings_override.enable()
self.addCleanup(self.cleanup_temp_assets_dir)
def cleanup_temp_assets_dir(self):
shutil.rmtree(self.assets_dir)
self.settings_override.disable()
def get_or_create_test_user(self):
if self.user is None:
self.user = User.objects.create_user(
@@ -182,6 +199,24 @@ class BookmarkFactoryMixin:
asset.save()
return asset
def setup_asset_file(self, asset: BookmarkAsset, file_content: str = "test"):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
if asset.gzip:
with gzip.open(filepath, "wb") as f:
f.write(file_content.encode())
else:
with open(filepath, "w") as f:
f.write(file_content)
def read_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
with open(filepath, "rb") as f:
return f.read()
def has_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
return os.path.exists(filepath)
def setup_tag(self, user: User = None, name: str = ""):
if user is None:
user = self.get_or_create_test_user()
@@ -290,6 +325,12 @@ class TagCloudTestMixin(TestCase, HtmlTestMixin):
class LinkdingApiTestCase(APITestCase):
def authenticate(self):
self.api_token = Token.objects.get_or_create(
user=self.get_or_create_test_user()
)[0]
self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key)
def get(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.get(url)
self.assertEqual(response.status_code, expected_status_code)

View File

@@ -0,0 +1,238 @@
import datetime
import gzip
import os
from unittest import mock
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.utils import timezone
from bookmarks.models import BookmarkAsset
from bookmarks.services import assets
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.setup_temp_assets_dir()
self.get_or_create_test_user()
self.html_content = "<html><body><h1>Hello, World!</h1></body></html>"
self.mock_singlefile_create_snapshot_patcher = mock.patch(
"bookmarks.services.singlefile.create_snapshot",
)
self.mock_singlefile_create_snapshot = (
self.mock_singlefile_create_snapshot_patcher.start()
)
self.mock_singlefile_create_snapshot.side_effect = lambda url, filepath: (
open(filepath, "w").write(self.html_content)
)
def tearDown(self) -> None:
self.mock_singlefile_create_snapshot_patcher.stop()
def get_saved_snapshot_file(self):
# look up first file in the asset folder
files = os.listdir(self.assets_dir)
if files:
return files[0]
def test_create_snapshot_asset(self):
bookmark = self.setup_bookmark()
asset = assets.create_snapshot_asset(bookmark)
self.assertIsNotNone(asset)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
self.assertIn("HTML snapshot from", asset.display_name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
# asset is not saved to the database
self.assertIsNone(asset.id)
def test_create_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.create_snapshot_asset(bookmark)
asset.save()
asset.date_created = timezone.datetime(
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
)
assets.create_snapshot(asset)
expected_temp_filename = "snapshot_2023-08-11_214511_https___example.com.tmp"
expected_temp_filepath = os.path.join(self.assets_dir, expected_temp_filename)
expected_filename = "snapshot_2023-08-11_214511_https___example.com.html.gz"
expected_filepath = os.path.join(self.assets_dir, expected_filename)
# should call singlefile.create_snapshot with the correct arguments
self.mock_singlefile_create_snapshot.assert_called_once_with(
"https://example.com",
expected_temp_filepath,
)
# should create gzip file in asset folder
self.assertTrue(os.path.exists(expected_filepath))
# gzip file should contain the correct content
with gzip.open(expected_filepath, "rb") as gz_file:
self.assertEqual(gz_file.read().decode(), self.html_content)
# should remove temporary file
self.assertFalse(os.path.exists(expected_temp_filepath))
# should update asset status and file
asset.refresh_from_db()
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, expected_filename)
self.assertTrue(asset.gzip)
def test_create_snapshot_failure(self):
bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.create_snapshot_asset(bookmark)
asset.save()
self.mock_singlefile_create_snapshot.side_effect = Exception
with self.assertRaises(Exception):
assets.create_snapshot(asset)
asset.refresh_from_db()
self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
def test_create_snapshot_truncates_asset_file_name(self):
# Create a bookmark with a very long URL
long_url = "http://" + "a" * 300 + ".com"
bookmark = self.setup_bookmark(url=long_url)
asset = assets.create_snapshot_asset(bookmark)
asset.save()
assets.create_snapshot(asset)
saved_file = self.get_saved_snapshot_file()
self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("snapshot_"))
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
def test_upload_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.upload_snapshot(bookmark, self.html_content.encode())
# should create gzip file in asset folder
saved_file_name = self.get_saved_snapshot_file()
self.assertIsNotNone(saved_file_name)
# verify file name
self.assertTrue(saved_file_name.startswith("snapshot_"))
self.assertTrue(saved_file_name.endswith("_https___example.com.html.gz"))
# gzip file should contain the correct content
with gzip.open(os.path.join(self.assets_dir, saved_file_name), "rb") as gz_file:
self.assertEqual(gz_file.read().decode(), self.html_content)
# should create asset
self.assertIsNotNone(asset.id)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
self.assertIn("HTML snapshot from", asset.display_name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name)
self.assertTrue(asset.gzip)
def test_upload_snapshot_failure(self):
bookmark = self.setup_bookmark(url="https://example.com")
# make gzip.open raise an exception
with mock.patch("gzip.open") as mock_gzip_open:
mock_gzip_open.side_effect = Exception
with self.assertRaises(Exception):
assets.upload_snapshot(bookmark, b"invalid content")
# asset is not saved to the database
self.assertIsNone(BookmarkAsset.objects.first())
def test_upload_snapshot_truncates_asset_file_name(self):
# Create a bookmark with a very long URL
long_url = "http://" + "a" * 300 + ".com"
bookmark = self.setup_bookmark(url=long_url)
assets.upload_snapshot(bookmark, self.html_content.encode())
saved_file = self.get_saved_snapshot_file()
self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("snapshot_"))
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
@disable_logging
def test_upload_asset(self):
bookmark = self.setup_bookmark()
file_content = b"test content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
asset = assets.upload_asset(bookmark, upload_file)
# should create file in asset folder
saved_file_name = self.get_saved_snapshot_file()
self.assertIsNotNone(upload_file)
# verify file name
self.assertTrue(saved_file_name.startswith("upload_"))
self.assertTrue(saved_file_name.endswith("_test_file.txt"))
# file should contain the correct content
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
self.assertEqual(file.read(), file_content)
# should create asset
self.assertIsNotNone(asset.id)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, upload_file.content_type)
self.assertEqual(asset.display_name, upload_file.name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name)
self.assertEqual(asset.file_size, len(file_content))
self.assertFalse(asset.gzip)
@disable_logging
def test_upload_asset_truncates_asset_file_name(self):
# Create a bookmark with a very long URL
long_file_name = "a" * 300 + ".txt"
bookmark = self.setup_bookmark()
file_content = b"test content"
upload_file = SimpleUploadedFile(
long_file_name, file_content, content_type="text/plain"
)
assets.upload_asset(bookmark, upload_file)
saved_file = self.get_saved_snapshot_file()
self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("upload_"))
self.assertTrue(saved_file.endswith("aaaa.txt"))
@disable_logging
def test_upload_asset_failure(self):
bookmark = self.setup_bookmark()
upload_file = SimpleUploadedFile("test_file.txt", b"test content")
# make open raise an exception
with mock.patch("builtins.open") as mock_open:
mock_open.side_effect = Exception
with self.assertRaises(Exception):
assets.upload_asset(bookmark, upload_file)
# asset is not saved to the database
self.assertIsNone(BookmarkAsset.objects.first())

View File

@@ -0,0 +1,32 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.authtoken.models import Token
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class AuthApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def authenticate(self, keyword):
self.api_token = Token.objects.get_or_create(
user=self.get_or_create_test_user()
)[0]
self.client.credentials(HTTP_AUTHORIZATION=f"{keyword} {self.api_token.key}")
def test_auth_with_token_keyword(self):
self.authenticate("Token")
url = reverse("bookmarks:user-profile")
self.get(url, expected_status_code=status.HTTP_200_OK)
def test_auth_with_bearer_keyword(self):
self.authenticate("Bearer")
url = reverse("bookmarks:user-profile")
self.get(url, expected_status_code=status.HTTP_200_OK)
def test_auth_with_unknown_keyword(self):
self.authenticate("Key")
url = reverse("bookmarks:user-profile")
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -8,7 +8,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from bookmarks.models import Bookmark, BookmarkAsset
from bookmarks.services import tasks, bookmarks
from bookmarks.services import assets, tasks
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
@@ -200,7 +200,7 @@ class BookmarkActionViewTestCase(
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
with patch.object(assets, "upload_asset") as mock_upload_asset:
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
@@ -221,7 +221,7 @@ class BookmarkActionViewTestCase(
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
with patch.object(assets, "upload_asset") as mock_upload_asset:
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
@@ -230,6 +230,27 @@ class BookmarkActionViewTestCase(
mock_upload_asset.assert_not_called()
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_upload_asset_disabled(self):
bookmark = self.setup_bookmark()
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 403)
def test_upload_asset_without_file(self):
bookmark = self.setup_bookmark()
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id},
)
self.assertEqual(response.status_code, 400)
def test_remove_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)

View File

@@ -503,3 +503,10 @@ class BookmarkArchivedViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_does_not_include_rss_feed(self):
response = self.client.get(reverse("bookmarks:archived"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)

View File

@@ -5,11 +5,11 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkArchivedViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None:
@@ -32,9 +32,10 @@ class BookmarkArchivedViewPerformanceTestCase(
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse("bookmarks:archived"))
self.assertContains(
response, "<li ld-bookmark-item>", num_initial_bookmarks
)
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries
@@ -46,8 +47,9 @@ class BookmarkArchivedViewPerformanceTestCase(
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:archived"))
self.assertContains(
response,
"<li ld-bookmark-item>",
num_initial_bookmarks + num_additional_bookmarks,
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
)

View File

@@ -11,19 +11,11 @@ from bookmarks.tests.helpers import (
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.setup_temp_assets_dir()
user = self.get_or_create_test_user()
self.client.force_login(user)
def tearDown(self):
temp_files = [
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")

View File

@@ -3,23 +3,15 @@ import os
from django.conf import settings
from django.test import TestCase
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
)
from bookmarks.services import bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
def tearDown(self):
temp_files = [
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
def setUp(self):
self.setup_temp_assets_dir()
def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")

View File

@@ -0,0 +1,340 @@
import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from bookmarks.models import BookmarkAsset
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarkAssetsApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self):
self.setup_temp_assets_dir()
def assertAsset(self, asset: BookmarkAsset, data: dict):
self.assertEqual(asset.id, data["id"])
self.assertEqual(asset.bookmark.id, data["bookmark"])
self.assertEqual(
asset.date_created.isoformat().replace("+00:00", "Z"), data["date_created"]
)
self.assertEqual(asset.file_size, data["file_size"])
self.assertEqual(asset.asset_type, data["asset_type"])
self.assertEqual(asset.content_type, data["content_type"])
self.assertEqual(asset.display_name, data["display_name"])
self.assertEqual(asset.status, data["status"])
def test_asset_list(self):
self.authenticate()
bookmark1 = self.setup_bookmark(url="https://example1.com")
bookmark1_assets = [
self.setup_asset(bookmark=bookmark1),
self.setup_asset(bookmark=bookmark1),
self.setup_asset(bookmark=bookmark1),
]
bookmark2 = self.setup_bookmark(url="https://example2.com")
bookmark2_assets = [
self.setup_asset(bookmark=bookmark2),
self.setup_asset(bookmark=bookmark2),
self.setup_asset(bookmark=bookmark2),
]
url = reverse(
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark1.id}
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 3)
self.assertAsset(bookmark1_assets[0], response.data["results"][0])
self.assertAsset(bookmark1_assets[1], response.data["results"][1])
self.assertAsset(bookmark1_assets[2], response.data["results"][2])
url = reverse(
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark2.id}
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 3)
self.assertAsset(bookmark2_assets[0], response.data["results"][0])
self.assertAsset(bookmark2_assets[1], response.data["results"][1])
self.assertAsset(bookmark2_assets[2], response.data["results"][2])
def test_asset_list_only_returns_assets_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark.id}
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_list_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse(
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark.id}
)
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def test_asset_detail(self):
self.authenticate()
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
file="cats.png",
file_size=1234,
content_type="image/png",
display_name="cats.png",
status=BookmarkAsset.STATUS_PENDING,
gzip=False,
)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertAsset(asset, response.data)
def test_asset_detail_only_returns_asset_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_detail_requires_authentication(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def test_asset_download_with_snapshot_asset(self):
self.authenticate()
file_content = """
<html>
<head>
<title>Test</title>
</head>
<body>
<h1>Test</h1>
</body>
"""
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
display_name="Snapshot from today",
content_type="text/html",
gzip=True,
)
self.setup_asset_file(asset=asset, file_content=file_content)
url = reverse(
"bookmarks:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(response["Content-Type"], "text/html")
self.assertEqual(
response["Content-Disposition"],
'attachment; filename="Snapshot from today.html"',
)
content = b"".join(response.streaming_content).decode("utf-8")
self.assertEqual(content, file_content)
def test_asset_download_with_uploaded_asset(self):
self.authenticate()
file_content = "some file content"
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
display_name="cats.png",
content_type="image/png",
gzip=False,
)
self.setup_asset_file(asset=asset, file_content=file_content)
url = reverse(
"bookmarks:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(response["Content-Type"], "image/png")
self.assertEqual(
response["Content-Disposition"],
'attachment; filename="cats.png"',
)
content = b"".join(response.streaming_content).decode("utf-8")
self.assertEqual(content, file_content)
def test_asset_download_with_missing_file(self):
self.authenticate()
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
display_name="cats.png",
content_type="image/png",
gzip=False,
)
url = reverse(
"bookmarks:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_download_only_returns_asset_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_download_requires_authentication(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def create_upload_body(self):
url = "https://example.com"
file_content = b"dummy content"
file = io.BytesIO(file_content)
file.name = "snapshot.html"
return {"url": url, "file": file}
def test_upload_asset(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse(
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
file_content = b"test file content"
file_name = "test.txt"
file = SimpleUploadedFile(file_name, file_content, content_type="text/plain")
response = self.client.post(url, {"file": file}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
asset = BookmarkAsset.objects.get(id=response.data["id"])
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.display_name, file_name)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, "text/plain")
self.assertEqual(asset.file_size, len(file_content))
self.assertFalse(asset.gzip)
content = self.read_asset_file(asset)
self.assertEqual(content, file_content)
def test_upload_asset_with_missing_file(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse(
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_upload_asset_only_works_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
url = reverse(
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_upload_asset_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse(
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_upload_asset_disabled(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse(
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_delete_asset(self):
self.authenticate()
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
self.setup_asset_file(asset=asset)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
self.assertFalse(self.has_asset_file(asset))
def test_delete_asset_only_works_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_delete_asset_requires_authentication(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -32,15 +32,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
modal = soup.find("turbo-frame", {"id": "details-modal"})
return modal
def find_section(self, soup, section_name):
dt = soup.find("dt", string=section_name)
dd = dt.find_next_sibling("dd") if dt else None
return dd
def find_section_content(self, soup, section_name):
h3 = soup.find("h3", string=section_name)
content = h3.find_next_sibling("div") if h3 else None
return content
def get_section(self, soup, section_name):
dd = self.find_section(soup, section_name)
self.assertIsNotNone(dd)
return dd
def get_section_content(self, soup, section_name):
content = self.find_section_content(soup, section_name)
self.assertIsNotNone(content)
return content
def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": url})
@@ -367,7 +367,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# sharing disabled
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived)
@@ -383,7 +383,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived)
@@ -395,7 +395,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# unchecked
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertFalse(archived.has_attr("checked"))
@@ -407,7 +407,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# checked
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertTrue(archived.has_attr("checked"))
@@ -420,14 +420,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# own bookmark
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Status")
section = self.find_section_content(soup, "Status")
self.assertIsNotNone(section)
# other user's bookmark
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status")
section = self.find_section_content(soup, "Status")
self.assertIsNone(section)
# guest user
@@ -436,13 +436,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status")
section = self.find_section_content(soup, "Status")
self.assertIsNone(section)
def test_date_added(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Date added")
section = self.get_section_content(soup, "Date added")
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
date = section.find("span", string=expected_date)
@@ -453,14 +453,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Tags")
section = self.find_section_content(soup, "Tags")
self.assertIsNone(section)
# with tags
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Tags")
section = self.get_section_content(soup, "Tags")
for tag in bookmark.tags.all():
tag_link = section.find("a", string=f"#{tag.name}")
@@ -473,14 +473,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark(description="")
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Description")
section = self.find_section_content(soup, "Description")
self.assertIsNone(section)
# with description
bookmark = self.setup_bookmark(description="Test description")
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Description")
section = self.get_section_content(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.description)
def test_notes(self):
@@ -488,14 +488,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Notes")
section = self.find_section_content(soup, "Notes")
self.assertIsNone(section)
# with notes
bookmark = self.setup_bookmark(notes="Test notes")
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Notes")
section = self.get_section_content(soup, "Notes")
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
def test_edit_link(self):
@@ -564,28 +564,12 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNone(edit_link)
self.assertIsNone(delete_button)
def test_assets_visibility_no_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_assets_visibility_with_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNotNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list_visibility(self):
# no assets
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNone(asset_list)
@@ -594,11 +578,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.setup_asset(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNotNone(asset_list)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list(self):
bookmark = self.setup_bookmark()
assets = [
@@ -608,7 +591,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
]
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
for asset in assets:
@@ -627,6 +610,76 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNotNone(view_link)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list_actions_visibility(self):
# own bookmark
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNotNone(create_snapshot)
self.assertIsNotNone(upload_asset)
# with sharing
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNone(upload_asset)
# with public sharing
profile = other_user.profile
profile.enable_public_sharing = True
profile.save()
soup = self.get_shared_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNone(upload_asset)
# guest user
self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
self.assertIsNone(delete_button)
def test_asset_list_actions_visibility_without_snapshots_enabled(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNotNone(upload_asset)
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_asset_list_actions_visibility_with_uploads_disabled(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNone(upload_asset)
def test_asset_without_file(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)
@@ -639,7 +692,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
view_link = asset_item.find("a", {"href": view_url})
self.assertIsNone(view_link)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_status(self):
bookmark = self.setup_bookmark()
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
@@ -655,7 +707,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_text = asset_item.select_one(".asset-text span")
self.assertIn("(failed)", asset_text.text)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_file_size(self):
bookmark = self.setup_bookmark()
asset1 = self.setup_asset(bookmark, file_size=None)
@@ -676,7 +727,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_text = asset_item.select_one(".asset-text")
self.assertIn("11.0\xa0MB", asset_text.text)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_actions_visibility(self):
bookmark = self.setup_bookmark()
@@ -738,7 +788,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# no pending asset
soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files")
files_section = self.find_section_content(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)
@@ -749,7 +799,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset.save()
soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files")
files_section = self.find_section_content(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)

View File

@@ -481,3 +481,10 @@ class BookmarkIndexViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_does_not_include_rss_feed(self):
response = self.client.get(reverse("bookmarks:index"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)

View File

@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
class BookmarkIndexViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@@ -30,9 +32,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(
response, "<li ld-bookmark-item>", num_initial_bookmarks
)
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries
@@ -44,8 +47,9 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(
response,
"<li ld-bookmark-item>",
num_initial_bookmarks + num_additional_bookmarks,
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
)

View File

@@ -0,0 +1,70 @@
import os
import shutil
import tempfile
from django.conf import settings
from django.test import TestCase, override_settings
from bookmarks.services import bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkPreviewsTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.override = override_settings(LD_PREVIEW_FOLDER=self.temp_dir)
self.override.enable()
def tearDown(self):
self.override.disable()
shutil.rmtree(self.temp_dir)
def setup_preview_file(self, filename):
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")
def setup_bookmark_with_preview(self):
bookmark = self.setup_bookmark()
bookmark.preview_image_file = f"preview_{bookmark.id}.jpg"
bookmark.save()
self.setup_preview_file(bookmark.preview_image_file)
return bookmark
def assertPreviewImageExists(self, bookmark):
self.assertTrue(
os.path.exists(
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
)
)
def assertPreviewImageDoesNotExist(self, bookmark):
self.assertFalse(
os.path.exists(
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
)
)
def test_delete_bookmark_deletes_preview_image(self):
bookmark = self.setup_bookmark_with_preview()
self.assertPreviewImageExists(bookmark)
bookmark.delete()
self.assertPreviewImageDoesNotExist(bookmark)
def test_bulk_delete_bookmarks_deletes_preview_images(self):
bookmark1 = self.setup_bookmark_with_preview()
bookmark2 = self.setup_bookmark_with_preview()
bookmark3 = self.setup_bookmark_with_preview()
self.assertPreviewImageExists(bookmark1)
self.assertPreviewImageExists(bookmark2)
self.assertPreviewImageExists(bookmark3)
bookmarks.delete_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertPreviewImageDoesNotExist(bookmark1)
self.assertPreviewImageDoesNotExist(bookmark2)
self.assertPreviewImageDoesNotExist(bookmark3)

View File

@@ -71,19 +71,15 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
radios = form.select(f'input[name="{name}"][type="radio"]')
self.assertTrue(len(radios) == 0)
def assertUnmodifiedLabel(self, html: str, text: str, id: str = ""):
id_attr = f'for="{id}"' if id else ""
tag = "label" if id else "div"
needle = f'<{tag} class="form-label" {id_attr}>{text}</{tag}>'
def assertUnmodifiedLabel(self, html: str, text: str):
soup = self.make_soup(html)
label = soup.find("label", string=lambda s: s and s.strip() == text)
self.assertEqual(label["class"], ["form-label"])
self.assertInHTML(needle, html)
def assertModifiedLabel(self, html: str, text: str, id: str = ""):
id_attr = f'for="{id}"' if id else ""
tag = "label" if id else "div"
needle = f'<{tag} class="form-label text-bold" {id_attr}>{text}</{tag}>'
self.assertInHTML(needle, html)
def assertModifiedLabel(self, html: str, text: str):
soup = self.make_soup(html)
label = soup.find("label", string=lambda s: s and s.strip() == text)
self.assertEqual(label["class"], ["form-label", "text-bold"])
def test_search_form_inputs(self):
# Without params
@@ -190,54 +186,53 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
# Without modifications
url = "/test"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn(
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
self.assertNotIn("badge", button["class"])
# With modifications
url = "/test?sort=title_asc"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn(
'<button type="button" class="btn dropdown-toggle badge">',
rendered_template,
)
self.assertIn("badge", button["class"])
# Ignores non-preferences modifications
url = "/test?q=foo&user=john"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn(
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
self.assertNotIn("badge", button["class"])
def test_modified_labels(self):
# Without modifications
url = "/test"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified sort
url = "/test?sort=title_asc"
rendered_template = self.render_template(url)
self.assertModifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertModifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified shared
url = "/test?shared=yes"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertModifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified unread
url = "/test?unread=yes"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertModifiedLabel(rendered_template, "Unread filter")

View File

@@ -593,3 +593,11 @@ class BookmarkSharedViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_includes_public_shared_rss_feed(self):
response = self.client.get(reverse("bookmarks:shared"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNotNone(feed)
self.assertEqual(feed.attrs["href"], reverse("bookmarks:feeds.public_shared"))

View File

@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
class BookmarkSharedViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@@ -31,9 +33,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse("bookmarks:shared"))
self.assertContains(
response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks
)
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries
@@ -46,8 +49,9 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:shared"))
self.assertContains(
response,
'<li ld-bookmark-item class="shared">',
num_initial_bookmarks + num_additional_bookmarks,
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
)

View File

@@ -1,21 +1,39 @@
import datetime
import io
import urllib.parse
from collections import OrderedDict
from unittest.mock import patch
from unittest.mock import patch, ANY
from django.contrib.auth.models import User
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
import bookmarks.services.bookmarks
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile
from bookmarks.services import website_loader
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
from bookmarks.utils import app_version
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self):
self.mock_assets_upload_snapshot_patcher = patch(
"bookmarks.services.assets.upload_snapshot",
)
self.mock_assets_upload_snapshot = (
self.mock_assets_upload_snapshot_patcher.start()
)
def tearDown(self):
self.mock_assets_upload_snapshot_patcher.stop()
def authenticate(self):
self.api_token = Token.objects.get_or_create(
user=self.get_or_create_test_user()
@@ -33,7 +51,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation["title"] = bookmark.title
expectation["description"] = bookmark.description
expectation["notes"] = bookmark.notes
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
expectation["web_archive_snapshot_url"] = (
bookmark.web_archive_snapshot_url
or generate_fallback_webarchive_url(bookmark.url, bookmark.date_added)
)
expectation["favicon_url"] = (
f"http://testserver/static/{bookmark.favicon_file}"
if bookmark.favicon_file
@@ -433,6 +454,40 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.title, "")
self.assertEqual(bookmark.description, "")
def test_create_bookmark_creates_html_snapshot_by_default(self):
self.authenticate()
with patch.object(
bookmarks.services.bookmarks,
"create_bookmark",
wraps=bookmarks.services.bookmarks.create_bookmark,
) as mock_create_bookmark:
data = {"url": "https://example.com/"}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
mock_create_bookmark.assert_called_with(
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=False
)
def test_create_bookmark_does_not_create_html_snapshot_if_disabled(self):
self.authenticate()
with patch.object(
bookmarks.services.bookmarks,
"create_bookmark",
wraps=bookmarks.services.bookmarks.create_bookmark,
) as mock_create_bookmark:
data = {"url": "https://example.com/"}
self.post(
reverse("bookmarks:bookmark-list") + "?disable_html_snapshot",
data,
status.HTTP_201_CREATED,
)
mock_create_bookmark.assert_called_with(
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=True
)
def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
self.authenticate()
@@ -590,6 +645,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [bookmark])
def test_get_bookmark_returns_fallback_webarchive_url(self):
self.authenticate()
bookmark = self.setup_bookmark(
web_archive_snapshot_url="",
url="https://example.com/",
added=timezone.datetime(
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
),
)
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(
response.data["web_archive_snapshot_url"],
"https://web.archive.org/web/20230811214511/https://example.com/",
)
def test_update_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
@@ -1074,6 +1146,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(
response.data["search_preferences"], profile.search_preferences
)
self.assertEqual(response.data["version"], app_version)
def test_user_profile(self):
self.authenticate()
@@ -1107,3 +1180,119 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertUserProfile(response, profile)
def create_singlefile_upload_body(self):
url = "https://example.com"
file_content = b"dummy content"
file = io.BytesIO(file_content)
file.name = "snapshot.html"
return {"url": url, "file": file}
def test_singlefile_upload(self):
bookmark = self.setup_bookmark(url="https://example.com")
self.authenticate()
response = self.client.post(
reverse("bookmarks:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
self.assertEqual(response.data["message"], "Snapshot uploaded successfully.")
self.mock_assets_upload_snapshot.assert_called_once()
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
def test_singlefile_creates_bookmark_if_not_exists(self):
other_user = self.setup_user()
self.setup_bookmark(url="https://example.com", user=other_user)
self.authenticate()
self.client.post(
reverse("bookmarks:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
self.assertEqual(Bookmark.objects.count(), 2)
bookmark = Bookmark.objects.get(
url="https://example.com", owner=self.get_or_create_test_user()
)
self.mock_assets_upload_snapshot.assert_called_once()
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
def test_singlefile_updates_own_bookmark_if_exists(self):
bookmark = self.setup_bookmark(url="https://example.com")
other_user = self.setup_user()
self.setup_bookmark(url="https://example.com", user=other_user)
self.authenticate()
self.client.post(
reverse("bookmarks:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
self.assertEqual(Bookmark.objects.count(), 2)
self.mock_assets_upload_snapshot.assert_called_once()
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
def test_singlefile_creates_bookmark_without_creating_snapshot(self):
with patch(
"bookmarks.services.bookmarks.create_bookmark"
) as mock_create_bookmark:
self.authenticate()
self.client.post(
reverse("bookmarks:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
mock_create_bookmark.assert_called_once()
mock_create_bookmark.assert_called_with(
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=True
)
def test_singlefile_upload_missing_parameters(self):
self.authenticate()
# Missing 'url'
file_content = b"dummy content"
file = io.BytesIO(file_content)
file.name = "snapshot.html"
response = self.client.post(
reverse("bookmarks:bookmark-singlefile"),
{"file": file},
format="multipart",
expected_status_code=status.HTTP_400_BAD_REQUEST,
)
self.assertEqual(
response.data["error"], "Both 'url' and 'file' parameters are required."
)
# Missing 'file'
response = self.client.post(
reverse("bookmarks:bookmark-singlefile"),
{"url": "https://example.com"},
format="multipart",
expected_status_code=status.HTTP_400_BAD_REQUEST,
)
self.assertEqual(
response.data["error"], "Both 'url' and 'file' parameters are required."
)
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_singlefile_upload_disabled(self):
self.authenticate()
self.client.post(
reverse("bookmarks:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_403_FORBIDDEN,
)

View File

@@ -162,3 +162,8 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
self.authenticate()
self.get(url, expected_status_code=status.HTTP_200_OK)
def test_singlefile_upload_requires_authentication(self):
url = reverse("bookmarks:bookmark-singlefile")
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -69,7 +69,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
details_url = base_url + f"?details={bookmark.id}"
self.assertInHTML(
f"""
<a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
<a href="{details_url}" class="view-action" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
""",
html,
count=count,
@@ -562,8 +562,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_should_reflect_unread_state_as_css_class(self):
self.setup_bookmark(unread=True)
html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="unread">', html)
list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["unread"], list_item["class"])
def test_should_reflect_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile
@@ -572,8 +575,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=True)
html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="shared">', html)
list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["shared"], list_item["class"])
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile
@@ -582,8 +588,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(unread=True, shared=True)
html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="unread shared">', html)
list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["unread", "shared"], list_item["class"])
def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark()

View File

@@ -1,13 +1,10 @@
import os
import tempfile
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.test import TestCase
from django.utils import timezone
from bookmarks.models import Bookmark, BookmarkAsset, Tag
from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services.bookmarks import (
@@ -24,7 +21,6 @@ from bookmarks.services.bookmarks import (
mark_bookmarks_as_unread,
share_bookmarks,
unshare_bookmarks,
upload_asset,
enhance_with_website_metadata,
)
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -110,6 +106,15 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_create_html_snapshot.assert_called_once_with(bookmark)
def test_create_should_not_load_html_snapshot_when_disabled(self):
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
bookmark_data = Bookmark(url="https://example.com")
create_bookmark(
bookmark_data, "tag1,tag2", self.user, disable_html_snapshot=True
)
mock_create_html_snapshot.assert_not_called()
def test_create_should_not_load_html_snapshot_when_setting_is_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_automatic_html_snapshots = False
@@ -850,53 +855,6 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_upload_asset_should_save_file(self):
bookmark = self.setup_bookmark()
with tempfile.TemporaryDirectory() as temp_assets:
with override_settings(LD_ASSET_FOLDER=temp_assets):
file_content = b"file content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
upload_asset(bookmark, upload_file)
assets = bookmark.bookmarkasset_set.all()
self.assertEqual(1, len(assets))
asset = assets[0]
self.assertEqual("test_file.txt", asset.display_name)
self.assertEqual("text/plain", asset.content_type)
self.assertEqual(upload_file.size, asset.file_size)
self.assertEqual(BookmarkAsset.STATUS_COMPLETE, asset.status)
self.assertTrue(asset.file.startswith("upload_"))
self.assertTrue(asset.file.endswith(upload_file.name))
# check file exists
filepath = os.path.join(temp_assets, asset.file)
self.assertTrue(os.path.exists(filepath))
with open(filepath, "rb") as f:
self.assertEqual(file_content, f.read())
def test_upload_asset_should_be_failed_if_saving_file_fails(self):
bookmark = self.setup_bookmark()
# Use an invalid path to force an error
with override_settings(LD_ASSET_FOLDER="/non/existing/folder"):
file_content = b"file content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
upload_asset(bookmark, upload_file)
assets = bookmark.bookmarkasset_set.all()
self.assertEqual(1, len(assets))
asset = assets[0]
self.assertEqual("test_file.txt", asset.display_name)
self.assertEqual("text/plain", asset.content_type)
self.assertIsNone(asset.file_size)
self.assertEqual(BookmarkAsset.STATUS_FAILURE, asset.status)
self.assertEqual("", asset.file)
def test_enhance_with_website_metadata(self):
bookmark = self.setup_bookmark(url="https://example.com")
with patch.object(

View File

@@ -1,15 +1,13 @@
import os.path
from unittest import mock
import waybackpy
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from huey.contrib.djhuey import HUEY as huey
from waybackpy.exceptions import WaybackError
from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import tasks, singlefile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -46,11 +44,11 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.mock_load_favicon = self.mock_load_favicon_patcher.start()
self.mock_load_favicon.return_value = "https_example_com.png"
self.mock_singlefile_create_snapshot_patcher = mock.patch(
"bookmarks.services.singlefile.create_snapshot",
self.mock_assets_create_snapshot_patcher = mock.patch(
"bookmarks.services.assets.create_snapshot",
)
self.mock_singlefile_create_snapshot = (
self.mock_singlefile_create_snapshot_patcher.start()
self.mock_assets_create_snapshot = (
self.mock_assets_create_snapshot_patcher.start()
)
self.mock_load_preview_image_patcher = mock.patch(
@@ -70,7 +68,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def tearDown(self):
self.mock_save_api_patcher.stop()
self.mock_load_favicon_patcher.stop()
self.mock_singlefile_create_snapshot_patcher.stop()
self.mock_assets_create_snapshot_patcher.stop()
self.mock_load_preview_image_patcher.stop()
huey.storage.flush_results()
huey.immediate = False
@@ -488,72 +486,31 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.assertIn("HTML snapshot", asset.display_name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
self.mock_assets_create_snapshot.assert_not_called()
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_update_file_info(self):
def test_schedule_html_snapshots_should_create_snapshots(self):
bookmark = self.setup_bookmark(url="https://example.com")
with mock.patch(
"bookmarks.services.tasks._generate_snapshot_filename"
) as mock_generate:
expected_filename = "snapshot_2021-01-02_034455_https___example.com.html.gz"
mock_generate.return_value = expected_filename
tasks.create_html_snapshot(bookmark)
BookmarkAsset.objects.get(bookmark=bookmark)
# Run periodic task to process the snapshot
tasks._schedule_html_snapshots_task()
self.mock_singlefile_create_snapshot.assert_called_once_with(
"https://example.com",
os.path.join(
settings.LD_ASSET_FOLDER,
expected_filename,
),
)
asset = BookmarkAsset.objects.get(bookmark=bookmark)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, expected_filename)
self.assertTrue(asset.gzip)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_truncate_filename(self):
# Create a bookmark with a very long URL
long_url = "http://" + "a" * 300 + ".com"
bookmark = self.setup_bookmark(url=long_url)
tasks.create_html_snapshot(bookmark)
BookmarkAsset.objects.get(bookmark=bookmark)
# Run periodic task to process the snapshot
tasks._schedule_html_snapshots_task()
asset = BookmarkAsset.objects.get(bookmark=bookmark)
self.assertEqual(len(asset.file), 192)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_handle_error(self):
bookmark = self.setup_bookmark(url="https://example.com")
self.mock_singlefile_create_snapshot.side_effect = singlefile.SingleFileError(
"Error"
)
tasks.create_html_snapshot(bookmark)
tasks.create_html_snapshot(bookmark)
# Run periodic task to process the snapshot
assets = BookmarkAsset.objects.filter(bookmark=bookmark)
tasks._schedule_html_snapshots_task()
asset = BookmarkAsset.objects.get(bookmark=bookmark)
self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
self.assertEqual(asset.file, "")
self.assertFalse(asset.gzip)
# should call create_snapshot for each pending asset
self.assertEqual(self.mock_assets_create_snapshot.call_count, 3)
for asset in assets:
self.mock_assets_create_snapshot.assert_any_call(asset)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_handle_missing_bookmark(self):
def test_create_html_snapshot_should_handle_missing_asset(self):
tasks._create_html_snapshot_task(123)
self.mock_singlefile_create_snapshot.assert_not_called()
self.mock_assets_create_snapshot.assert_not_called()
@override_settings(LD_ENABLE_SNAPSHOTS=False)
def test_create_html_snapshot_should_not_create_asset_when_single_file_is_disabled(

View File

@@ -4,6 +4,8 @@ import os
from django.test import TestCase, override_settings
from django.urls import URLResolver
from bookmarks import utils
class OidcSupportTest(TestCase):
def test_should_not_add_oidc_urls_by_default(self):
@@ -55,9 +57,83 @@ class OidcSupportTest(TestCase):
base_settings = importlib.import_module("siteroot.settings.base")
importlib.reload(base_settings)
self.assertEqual(
True,
base_settings.OIDC_VERIFY_SSL,
)
self.assertEqual(True, base_settings.OIDC_VERIFY_SSL)
self.assertEqual("openid email profile", base_settings.OIDC_RP_SCOPES)
self.assertEqual("email", base_settings.OIDC_USERNAME_CLAIM)
del os.environ["LD_ENABLE_OIDC"]
del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="email")
def test_username_should_use_email_by_default(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "test preferred username",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["email"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
def test_username_should_use_custom_claim(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "test preferred username",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["preferred_username"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="nonexistant_claim")
def test_username_should_fallback_to_email_for_non_existing_claim(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "test preferred username",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["email"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
def test_username_should_fallback_to_email_for_empty_claim(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["email"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
def test_username_should_be_normalized(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual("NormalizedUser", username)

View File

@@ -47,6 +47,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"auto_tagging_rules": "",
"items_per_page": "30",
"sticky_pagination": False,
"collapse_side_panel": False,
}
return {**form_data, **overrides}
@@ -117,6 +118,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"auto_tagging_rules": "example.com tag",
"items_per_page": "10",
"sticky_pagination": True,
"collapse_side_panel": True,
}
response = self.client.post(
reverse("bookmarks:settings.update"), form_data, follow=True
@@ -194,6 +196,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.sticky_pagination, form_data["sticky_pagination"]
)
self.assertEqual(
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
)
self.assertSuccessMessage(html, "Profile updated")

View File

@@ -1,7 +1,6 @@
import secrets
import gzip
import os
import subprocess
import tempfile
from unittest import mock
from django.test import TestCase, override_settings
@@ -11,34 +10,14 @@ from bookmarks.services import singlefile
class SingleFileServiceTestCase(TestCase):
def setUp(self):
self.html_content = "<html><body><h1>Hello, World!</h1></body></html>"
self.html_filepath = secrets.token_hex(8) + ".html.gz"
self.temp_html_filepath = self.html_filepath + ".tmp"
self.temp_html_filepath = None
def tearDown(self):
if os.path.exists(self.html_filepath):
os.remove(self.html_filepath)
if os.path.exists(self.temp_html_filepath):
if self.temp_html_filepath and os.path.exists(self.temp_html_filepath):
os.remove(self.temp_html_filepath)
def create_test_file(self, *args, **kwargs):
with open(self.temp_html_filepath, "w") as file:
file.write(self.html_content)
def test_create_snapshot(self):
mock_process = mock.Mock()
mock_process.wait.return_value = 0
self.create_test_file()
with mock.patch("subprocess.Popen", return_value=mock_process):
singlefile.create_snapshot("http://example.com", self.html_filepath)
self.assertTrue(os.path.exists(self.html_filepath))
self.assertFalse(os.path.exists(self.temp_html_filepath))
with gzip.open(self.html_filepath, "rt") as file:
content = file.read()
self.assertEqual(content, self.html_content)
self.temp_html_filepath = tempfile.mkstemp(suffix=".tmp")[1]
def test_create_snapshot_failure(self):
# subprocess fails - which it probably doesn't as single-file doesn't return exit codes
@@ -46,12 +25,12 @@ class SingleFileServiceTestCase(TestCase):
mock_popen.side_effect = subprocess.CalledProcessError(1, "command")
with self.assertRaises(singlefile.SingleFileError):
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", "nonexistentfile.tmp")
# so also check that it raises error if output file isn't created
with mock.patch("subprocess.Popen"):
with self.assertRaises(singlefile.SingleFileError):
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", "nonexistentfile.tmp")
def test_create_snapshot_empty_options(self):
mock_process = mock.Mock()
@@ -59,7 +38,7 @@ class SingleFileServiceTestCase(TestCase):
self.create_test_file()
with mock.patch("subprocess.Popen") as mock_popen:
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
expected_args = [
"single-file",
@@ -68,7 +47,7 @@ class SingleFileServiceTestCase(TestCase):
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
"http://example.com",
self.html_filepath + ".tmp",
self.temp_html_filepath,
]
mock_popen.assert_called_with(expected_args, start_new_session=True)
@@ -81,7 +60,7 @@ class SingleFileServiceTestCase(TestCase):
self.create_test_file()
with mock.patch("subprocess.Popen") as mock_popen:
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
expected_args = [
"single-file",
@@ -95,7 +74,7 @@ class SingleFileServiceTestCase(TestCase):
"another value",
"--third-option=third value",
"http://example.com",
self.html_filepath + ".tmp",
self.temp_html_filepath,
]
mock_popen.assert_called_with(expected_args, start_new_session=True)
@@ -105,7 +84,7 @@ class SingleFileServiceTestCase(TestCase):
self.create_test_file()
with mock.patch("subprocess.Popen", return_value=mock_process):
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
mock_process.wait.assert_called_with(timeout=120)
@@ -116,6 +95,6 @@ class SingleFileServiceTestCase(TestCase):
self.create_test_file()
with mock.patch("subprocess.Popen", return_value=mock_process):
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
mock_process.wait.assert_called_with(timeout=180)

View File

@@ -2,7 +2,7 @@ from django.urls import path, include
from django.urls import re_path
from bookmarks import views
from bookmarks.api.routes import router
from bookmarks.api import routes as api_routes
from bookmarks.feeds import (
AllBookmarksFeed,
UnreadBookmarksFeed,
@@ -55,7 +55,14 @@ urlpatterns = [
# Toasts
path("toasts/acknowledge", views.toasts.acknowledge, name="toasts.acknowledge"),
# API
path("api/", include(router.urls), name="api"),
path("api/", include(api_routes.default_router.urls)),
path("api/bookmarks/", include(api_routes.bookmark_router.urls)),
path(
"api/bookmarks/<int:bookmark_id>/assets/",
include(api_routes.bookmark_asset_router.urls),
),
path("api/tags/", include(api_routes.tag_router.urls)),
path("api/user/", include(api_routes.user_router.urls)),
# Feeds
path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"),
path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"),

View File

@@ -9,6 +9,7 @@ from dateutil.relativedelta import relativedelta
from django.http import HttpResponseRedirect
from django.template.defaultfilters import pluralize
from django.utils import timezone, formats
from django.conf import settings
try:
with open("version.txt", "r") as f:
@@ -128,10 +129,13 @@ def redirect_with_query(request, redirect_url):
return HttpResponseRedirect(redirect_url)
def generate_username(email):
def generate_username(email, claims):
# taken from mozilla-django-oidc docs :)
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
# (ascii and unicode), _, @, +, . and - characters. So we normalize
# it and slice at 150 characters.
return unicodedata.normalize("NFKC", email)[:150]
if settings.OIDC_USERNAME_CLAIM in claims and claims[settings.OIDC_USERNAME_CLAIM]:
username = claims[settings.OIDC_USERNAME_CLAIM]
else:
username = email
return unicodedata.normalize("NFKC", username)[:150]

View File

@@ -1,5 +1,6 @@
import urllib.parse
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.db.models import QuerySet
from django.http import (
@@ -19,7 +20,7 @@ from bookmarks.models import (
BookmarkSearch,
build_tag_string,
)
from bookmarks.services import bookmarks as bookmark_actions, tasks
from bookmarks.services import assets as asset_actions, tasks
from bookmarks.services.bookmarks import (
create_bookmark,
update_bookmark,
@@ -104,6 +105,7 @@ def shared(request):
"tag_cloud": tag_cloud,
"details": bookmark_details,
"users": users,
"rss_feed_url": reverse("bookmarks:feeds.public_shared"),
},
)
@@ -277,6 +279,9 @@ def create_html_snapshot(request, bookmark_id: int):
def upload_asset(request, bookmark_id: int):
if settings.LD_DISABLE_ASSET_UPLOAD:
return HttpResponseForbidden("Asset upload is disabled")
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
@@ -284,9 +289,9 @@ def upload_asset(request, bookmark_id: int):
file = request.FILES.get("upload_asset_file")
if not file:
raise ValueError("No file uploaded")
return HttpResponseBadRequest("No file provided")
bookmark_actions.upload_asset(bookmark, file)
asset_actions.upload_asset(bookmark, file)
def remove_asset(request, asset_id: int):
@@ -314,7 +319,10 @@ def update_state(request, bookmark_id: int):
def index_action(request):
search = BookmarkSearch.from_request(request.GET)
query = queries.query_bookmarks(request.user, request.user_profile, search)
handle_action(request, query)
response = handle_action(request, query)
if response:
return response
if turbo.accept(request):
return partials.active_bookmark_update(request)
@@ -326,7 +334,10 @@ def index_action(request):
def archived_action(request):
search = BookmarkSearch.from_request(request.GET)
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
handle_action(request, query)
response = handle_action(request, query)
if response:
return response
if turbo.accept(request):
return partials.archived_bookmark_update(request)
@@ -339,7 +350,9 @@ def shared_action(request):
if "bulk_execute" in request.POST:
return HttpResponseBadRequest("View does not support bulk actions")
handle_action(request)
response = handle_action(request)
if response:
return response
if turbo.accept(request):
return partials.shared_bookmark_update(request)
@@ -350,25 +363,25 @@ def shared_action(request):
def handle_action(request, query: QuerySet[Bookmark] = None):
# Single bookmark actions
if "archive" in request.POST:
archive(request, request.POST["archive"])
return archive(request, request.POST["archive"])
if "unarchive" in request.POST:
unarchive(request, request.POST["unarchive"])
return unarchive(request, request.POST["unarchive"])
if "remove" in request.POST:
remove(request, request.POST["remove"])
return remove(request, request.POST["remove"])
if "mark_as_read" in request.POST:
mark_as_read(request, request.POST["mark_as_read"])
return mark_as_read(request, request.POST["mark_as_read"])
if "unshare" in request.POST:
unshare(request, request.POST["unshare"])
return unshare(request, request.POST["unshare"])
if "create_html_snapshot" in request.POST:
create_html_snapshot(request, request.POST["create_html_snapshot"])
return create_html_snapshot(request, request.POST["create_html_snapshot"])
if "upload_asset" in request.POST:
upload_asset(request, request.POST["upload_asset"])
return upload_asset(request, request.POST["upload_asset"])
if "remove_asset" in request.POST:
remove_asset(request, request.POST["remove_asset"])
return remove_asset(request, request.POST["remove_asset"])
# State updates
if "update_state" in request.POST:
update_state(request, request.POST["update_state"])
return update_state(request, request.POST["update_state"])
# Bulk actions
if "bulk_execute" in request.POST:
@@ -386,25 +399,25 @@ def handle_action(request, query: QuerySet[Bookmark] = None):
bookmark_ids = request.POST.getlist("bookmark_id")
if "bulk_archive" == bulk_action:
archive_bookmarks(bookmark_ids, request.user)
return archive_bookmarks(bookmark_ids, request.user)
if "bulk_unarchive" == bulk_action:
unarchive_bookmarks(bookmark_ids, request.user)
return unarchive_bookmarks(bookmark_ids, request.user)
if "bulk_delete" == bulk_action:
delete_bookmarks(bookmark_ids, request.user)
return delete_bookmarks(bookmark_ids, request.user)
if "bulk_tag" == bulk_action:
tag_string = convert_tag_string(request.POST["bulk_tag_string"])
tag_bookmarks(bookmark_ids, tag_string, request.user)
return tag_bookmarks(bookmark_ids, tag_string, request.user)
if "bulk_untag" == bulk_action:
tag_string = convert_tag_string(request.POST["bulk_tag_string"])
untag_bookmarks(bookmark_ids, tag_string, request.user)
return untag_bookmarks(bookmark_ids, tag_string, request.user)
if "bulk_read" == bulk_action:
mark_bookmarks_as_read(bookmark_ids, request.user)
return mark_bookmarks_as_read(bookmark_ids, request.user)
if "bulk_unread" == bulk_action:
mark_bookmarks_as_unread(bookmark_ids, request.user)
return mark_bookmarks_as_unread(bookmark_ids, request.user)
if "bulk_share" == bulk_action:
share_bookmarks(bookmark_ids, request.user)
return share_bookmarks(bookmark_ids, request.user)
if "bulk_unshare" == bulk_action:
unshare_bookmarks(bookmark_ids, request.user)
return unshare_bookmarks(bookmark_ids, request.user)
@login_required

View File

@@ -208,6 +208,7 @@ class BookmarkListContext:
self.show_favicons = user_profile.enable_favicons
self.show_preview_images = user_profile.enable_preview_images
self.show_notes = user_profile.permanent_notes
self.collapse_side_panel = user_profile.collapse_side_panel
@staticmethod
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
@@ -358,7 +359,6 @@ class BookmarkAssetItem:
self.id = asset.id
self.display_name = asset.display_name
self.asset_type = asset.asset_type
self.content_type = asset.content_type
self.file = asset.file
self.file_size = asset.file_size
self.status = asset.status
@@ -398,8 +398,8 @@ class BookmarkDetailsContext:
self.sharing_enabled = user_profile.enable_sharing
self.preview_image_enabled = user_profile.enable_preview_images
self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
# For now hide files section if snapshots are not supported
self.show_files = settings.LD_ENABLE_SNAPSHOTS
self.snapshots_enabled = settings.LD_ENABLE_SNAPSHOTS
self.uploads_enabled = not settings.LD_DISABLE_ASSET_UPLOAD
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
if not self.web_archive_snapshot_url:

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS node-build
FROM node:18-alpine AS node-build
WORKDIR /etc/linkding
# install build dependencies
COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
RUN npm run build
FROM python:3.12.6-alpine3.20 AS build-deps
FROM python:3.12.9-alpine3.21 AS build-deps
# Add required packages
# alpine-sdk linux-headers pkgconfig: build Python packages from source
# libpq-dev: build Postgres client from source
@@ -49,7 +49,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
FROM python:3.12.6-alpine3.20 AS linkding
FROM python:3.12.9-alpine3.21 AS linkding
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
# install runtime dependencies
RUN apk update && apk add bash curl icu libpq mailcap libssl3
@@ -73,6 +73,8 @@ ENV PATH=/opt/venv/bin:$PATH
RUN mkdir data && \
python manage.py collectstatic
# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453
ENV UWSGI_MAX_FD=4096
# Expose uwsgi server at port 9090
EXPOSE 9090
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS node-build
FROM node:18-alpine AS node-build
WORKDIR /etc/linkding
# install build dependencies
COPY rollup.config.mjs postcss.config.js package.json package-lock.json ./
@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
RUN npm run build
FROM python:3.12.6-slim-bookworm AS build-deps
FROM python:3.12.9-slim-bookworm AS build-deps
# Add required packages
# build-essential pkg-config: build Python packages from source
# libpq-dev: build Postgres client from source
@@ -51,7 +51,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
FROM python:3.12.6-slim-bookworm AS linkding
FROM python:3.12.9-slim-bookworm AS linkding
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
# install runtime dependencies
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
@@ -71,6 +71,8 @@ ENV PATH=/opt/venv/bin:$PATH
RUN mkdir data && \
python manage.py collectstatic
# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453
ENV UWSGI_MAX_FD=4096
# Expose uwsgi server at port 9090
EXPOSE 9090
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman

View File

@@ -27,10 +27,11 @@ export default defineConfig({
label: 'Guides',
items: [
{ label: 'Backups', slug: 'backups' },
{ label: 'Admin', slug: 'admin' },
{ label: 'Archiving', slug: 'archiving' },
{ label: 'Keyboard Shortcuts', slug: 'shortcuts' },
{ label: 'How To', slug: 'how-to' },
{ label: 'Troubleshooting', slug: 'troubleshooting' },
{ label: 'Admin', slug: 'admin' },
{ label: 'REST API', slug: 'api' },
],
},

1199
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"dependencies": {
"@astrojs/check": "^0.9.3",
"@astrojs/starlight": "^0.27.1",
"astro": "^4.15.8",
"astro": "^4.16.18",
"sharp": "^0.32.5",
"typescript": "^5.6.2"
}

View File

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -9,9 +9,35 @@ description: "Acknowledgements and thanks to contributors and sponsors"
See the table below for a list of donations.
| Source | Description | Amount | Donated to |
|---------------------------------------|---------------------------------------------|---------|------------------------------------------------------------------|
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/2023-10-11-internet-archive.png) |
<table>
<thead>
<tr>
<th>Source</th>
<th>Description</th>
<th>Amount</th>
<th>Donated to</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://www.pikapods.com/">PikaPods</a></td>
<td>Linkding hosting June 2022 - September 2023</td>
<td>$163.50</td>
<td><a href="/donations/2023-10-11-internet-archive.png">Internet Archive</a></td>
</tr>
<tr>
<td><a href="https://www.pikapods.com/">PikaPods</a></td>
<td>Linkding hosting October 2023 - September 2024</td>
<td>$287.04</td>
<td>
<a href="/donations/2024-10-04-django.png">Django</a><br>
<a href="/donations/2024-10-04-singlefile.png">SingleFile</a><br>
<a href="/donations/2024-10-04-internet-archive.png">Internet Archive</a><br>
<a href="/donations/2024-10-04-noyb.png">NOYB</a>
</td>
</tr>
</tbody>
</table>
## JetBrains

View File

@@ -7,7 +7,8 @@ The application provides a REST API that can be used by 3rd party applications t
## Authentication
All requests against the API must be authorized using an authorization token. The application automatically generates an API token for each user, which can be accessed through the *Settings* page.
All requests against the API must be authorized using an authorization token. The application automatically generates an
API token for each user, which can be accessed through the *Settings* page.
The token needs to be passed as `Authorization` header in the HTTP request:
@@ -91,9 +92,11 @@ Retrieves a single bookmark by ID.
GET /api/bookmarks/check/?url=https%3A%2F%2Fexample.com
```
Allows to check if a URL is already bookmarked. If the URL is already bookmarked, the `bookmark` property in the response holds the bookmark data, otherwise it is `null`.
Allows to check if a URL is already bookmarked. If the URL is already bookmarked, the `bookmark` property in the
response holds the bookmark data, otherwise it is `null`.
Also returns a `metadata` property that contains metadata scraped from the website. Finally, the `auto_tags` property contains the tag names that would be automatically added when creating a bookmark for that URL.
Also returns a `metadata` property that contains metadata scraped from the website. Finally, the `auto_tags` property
contains the tag names that would be automatically added when creating a bookmark for that URL.
Example response:
@@ -127,9 +130,13 @@ POST /api/bookmarks/
Creates a new bookmark. Tags are simply assigned using their names. Including
`is_archived: true` saves a bookmark directly to the archive.
If the provided URL is already bookmarked, this silently updates the existing bookmark instead of creating a new one. If you are implementing a user interface, consider notifying users about this behavior. You can use the `/check` endpoint to check if a URL is already bookmarked and at the same time get the existing bookmark data. This behavior may change in the future to return an error instead.
If the provided URL is already bookmarked, this silently updates the existing bookmark instead of creating a new one. If
you are implementing a user interface, consider notifying users about this behavior. You can use the `/check` endpoint
to check if a URL is already bookmarked and at the same time get the existing bookmark data. This behavior may change in
the future to return an error instead.
If the title and description are not provided or empty, the application automatically tries to scrape them from the bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request.
If the title and description are not provided or empty, the application automatically tries to scrape them from the
bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request.
Example payload:
@@ -202,6 +209,96 @@ DELETE /api/bookmarks/<id>/
Deletes a bookmark by ID.
### Bookmark Assets
**List**
```
GET /api/bookmarks/<bookmark_id>/assets/
```
List assets for a specific bookmark.
Example response:
```json
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"bookmark": 1,
"asset_type": "snapshot",
"date_created": "2023-10-01T12:00:00Z",
"content_type": "text/html",
"display_name": "HTML snapshot from 10/01/2023",
"status": "complete",
"gzip": true
},
{
"id": 2,
"bookmark": 1,
"asset_type": "upload",
"date_created": "2023-10-01T12:05:00Z",
"content_type": "image/png",
"display_name": "example.png",
"status": "complete",
"gzip": false
}
]
}
```
**Retrieve**
```
GET /api/bookmarks/<bookmark_id>/assets/<id>/
```
Retrieves a single asset by ID for a specific bookmark.
**Download**
```
GET /api/bookmarks/<bookmark_id>/assets/<id>/download/
```
Downloads the asset file.
**Upload**
```
POST /api/bookmarks/<bookmark_id>/assets/upload/
```
Uploads a new asset for a specific bookmark. The request must be a `multipart/form-data` request with a single part
named `file` containing the file to upload.
Example response:
```json
{
"id": 3,
"bookmark": 1,
"asset_type": "upload",
"date_created": "2023-10-01T12:10:00Z",
"content_type": "application/pdf",
"display_name": "example.pdf",
"status": "complete",
"gzip": false
}
```
**Delete**
```
DELETE /api/bookmarks/<bookmark_id>/assets/<id>/
```
Deletes an asset by ID for a specific bookmark.
### Tags
**List**

View File

@@ -0,0 +1,54 @@
---
title: "Archiving"
description: "How to archive web pages with linkding"
---
Linkding allows to archive bookmarked web pages as HTML files. This can be useful to preserve the content of a web page in case it goes offline or its content changes. The sections below explain different methods to archive web pages and store them in linkding.
## Server-based Archiving
Linkding can automatically create HTML snapshots whenever a bookmark is added. This feature is only available in the `latest-plus` Docker image (see [Installation](/installation#using-docker)), and is automatically active when using that image.
The snapshots are created using [singlefile-cli](https://github.com/gildas-lormeau/single-file-cli), which effectively runs a headless Chromium instance on the server to convert the web page into a single HTML file. Linkding will also load the [uBlock Origin Lite extension](https://github.com/uBlockOrigin/uBOL-home) into Chromium to attempt to block ads and other unwanted content.
This method is fairly easy to set up, but also has several downsides:
- The Docker image is significantly larger than the base image, as it includes a Chromium installation.
- Running Chromium requires significantly more memory, at least 1 GB of RAM.
- The Docker image is not available for ARM v7 platforms.
- Creating snapshots from a headless browser is not always reliable, for example:
- It may trigger anti-bot measures on the website.
- Websites that require login will not show the same content you would see in your browser.
Read on for alternative methods to archive web pages directly from your browser, which do not require the `latest-plus` Docker image and will save the web page exactly as you see it in your browser.
## Using the Singlefile Browser Extension
[Singlefile](https://github.com/gildas-lormeau/SingleFile) is a popular browser extension for saving a web page as a single HTML file. By default, the extension saves the file to your local disk, but it can also be configured to send the file to a REST API endpoint. Linkding provides a REST API endpoint for this purpose.
To use the Singlefile extension with linkding, follow these steps:
- Install the [Singlefile extension](https://github.com/gildas-lormeau/SingleFile) in your browser
- Open the extension's settings
- Under `Destination`, select `upload to a REST Form API`
- Under `URL` enter the URL of your linkding installation, followed by `/api/bookmarks/singlefile/` (e.g. `https://linkding.example.com/api/bookmarks/singlefile/`)
- Under `authorization token`, enter the REST API token that you can get from your linkding settings page
- Under `data field name` enter `file`
- Under `URL field name` enter `url`
Now, when you click the Singlefile extension icon, the web page will be saved as a single HTML file and uploaded to your linkding installation.
Note that if a bookmark for that URL does not exist, linkding will automatically create a new bookmark. If a bookmark already exists, the snapshot will be added to the existing bookmark.
Using this method has some downsides, in that you can not provide any additional metadata to the bookmark, such as tags or a description. Read on for how to use the linkding extension directly to create bookmarks with metadata and run the Singlefile extension.
## Using the linkding Browser Extension
The linkding extension allows you to quickly add bookmarks from your browser. It can also integrate with the Singlefile extension to automatically create a snapshot of the web page when adding a bookmark.
To integrate with the Singlefile extension, follow these steps:
- First, install and configure the Singlefile extension as described above so that it uploads files to your linkding installation
- Open the linkding extension settings
- Enable the `Run Singlefile after adding new bookmark` option
- Save the settings
Now, when you add a bookmark through the linkding extension, it will automatically trigger the Singlefile extension to create a snapshot of the web page, which will then be uploaded to your linkding installation and stored under the newly added bookmark.
Note that when the option is enabled, linkding will not attempt to create an HTML snapshot on the server, even if you are using the `latest-plus` Docker image. The linkding extension will not trigger Singlefile when updating an existing bookmark. If you want to create a new snapshot for an existing bookmark, you can do so manually by clicking the Singlefile extension icon.

View File

@@ -6,17 +6,26 @@ description: "Community projects around linkding"
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to [submit a PR](https://github.com/sissbruecker/linkding/edit/master/docs/src/content/docs/community.md) to add your project to this section.
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
- [cosmicding](https://github.com/vkhitrin/cosmicding) Desktop client built using [libcosmic](https://github.com/pop-os/libcosmic). By [vkhitrin](https://github.com/vkhitrin)
- [DingDrop](https://github.com/marb08/DingDrop) A Telegram bot that allows you to quickly save bookmarks to your Linkding instance via Telegram using Linkding APIs. By [marb08](https://github.com/marb08)
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
- [go-linkding](https://github.com/piero-vic/go-linkding) A Go client library to interact with the linkding REST API. By [piero-vic](https://github.com/piero-vic)
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
- [iOS Shortcut and workflow](https://joshdick.net/2025/01/23/how_i_use_linkding_on_ios.html) iOS shortcut that accepts URLs in various ways, and shows a corresponding Linkding add/edit webview in a modal popup
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
- [linkding-archiver](https://github.com/sebw/linkding-archiver) A Python application that integrates with SingleFile and Tube Archivist to archive your links and videos. By [sebw](https://github.com/sebw)
- [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)
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-healthcheck](https://github.com/sebw/linkding-healthcheck) A Go application that checks the health of your bookmarks and add a tag on dead and problematic URLs. By [sebw](https://github.com/sebw)
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
- [linkding-reminder](https://github.com/sebw/linkding-reminder) A Python application that will send an email reminder for links with a specific tag. By [sebw](https://github.com/sebw)
- [linkding-rs](https://github.com/zbrox/linkding-rs) A Rust client library to interact with the linkding REST API with cross platform support to be easily used in Android or iOS apps. By [zbrox](https://github.com/zbrox)
- [Linkdy](https://github.com/JGeek00/linkdy): An open-source Android and iOS client created with Flutter. Available on the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy) and [App Store](https://apps.apple.com/us/app/linkdy/id6479930976). By [JGeek00](https://github.com/JGeek00).
- [Linklater](https://github.com/danielyrovas/linklater) An open-source Android client written in Kotlin.
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
- [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)
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
- [serchding](https://github.com/ldwgchen/serchding) Full-text search for linkding. By [ldwgchen](https://github.com/ldwgchen)

View File

@@ -69,7 +69,7 @@ You can also check the [Community section](/community) for other pre-made shortc
The font size can be adjusted globally by adding the following CSS to the custom CSS field in the settings:
```css
html {
:root {
--font-size: 0.75rem;
--font-size-sm: 0.7rem;
--font-size-lg: 0.9rem;

View File

@@ -19,7 +19,7 @@ For multiple options, use one `-e` argument per option.
### Docker-compose
For docker-compose options are configured using an `.env` file.
For docker-compose options are configured using an `.env` file.
Follow the docker-compose setup in the README and copy `.env.sample` to `.env`. Then modify the options in `.env`.
## List of options
@@ -55,6 +55,12 @@ Values: `True`, `False` | Default = `False`
Completely disables URL validation for bookmarks.
This can be useful if you intend to store non fully qualified domain name URLs, such as network paths, or you want to store URLs that use another protocol than `http` or `https`.
### `LD_REQUEST_MAX_CONTENT_LENGTH`
Values: `Integer` as bytes | Default = `None`
Configures the maximum content length for POST requests in the uwsgi application server. This can be used to prevent uploading large files that might cause the server to run out of memory. By default, the server does not limit the content length.
### `LD_REQUEST_TIMEOUT`
Values: `Integer` as seconds | Default = `60`
@@ -105,11 +111,11 @@ Values: `True`, `False` | Default = `False`
Enables support for OpenID Connect (OIDC) authentication, allowing to use single sign-on (SSO) with OIDC providers.
When enabled, this shows a button on the login page that allows users to authenticate using an OIDC provider.
Users are associated by the email address provided from the OIDC provider, which is used as the username in linkding.
If there is no user with that email address as username, a new user is created automatically.
Users are associated by the email address provided from the OIDC provider, which is by default also used as username in linkding. You can configure a custom claim to be used as username with `OIDC_USERNAME_CLAIM`.
If there is no user with that email address as username, a new user is created automatically.
This requires configuring a number of options, which of those you need depends on which OIDC provider you use and how it is configured.
In general, you should find the required information in the UI of your OIDC provider, or its documentation.
In general, you should find the required information in the UI of your OIDC provider, or its documentation.
The options are adopted from the [mozilla-django-oidc](https://mozilla-django-oidc.readthedocs.io/en/stable/) library, which is used by linkding for OIDC support.
Please check their documentation for more information on the options.
@@ -124,6 +130,15 @@ The following options can be configured:
- `OIDC_RP_SIGN_ALGO` - The algorithm the OIDC provider uses to sign ID tokens. Default is `RS256`.
- `OIDC_USE_PKCE` - Whether to use PKCE for the OIDC flow. Default is `True`.
- `OIDC_VERIFY_SSL` - Whether to verify the SSL certificate of the OIDC provider. Set to `False` if using self-signed certificates or custom certificate authority. Default is `True`.
- `OIDC_RP_SCOPES` - Scopes asked for on the authorization flow. Default is `oidc email profile`.
- `OIDC_USERNAME_CLAIM` - A custom claim to used as username for new accounts, for example `preferred_username`. If the configured claim does not exist or is empty, the email claim is used as fallback. Default is `email`.
#### `OIDC` and `LD_SUPERUSER_NAME`
As noted above, OIDC matches users by email address, but `LD_SUPERUSER_NAME` will only set the username.
Instead of setting `LD_SUPERUSER_NAME` it is recommended that you use the method described in [User setup](/installation#user-setup) to configure a superuser with both username and email address.
This way when OIDC searches for a matching user it will find the superuser account you created.
Note that you should create the superuser **before** logging in with OIDC for the first time.
<details>
@@ -198,7 +213,7 @@ All the other database variables below are only required for configured Postgres
Values: `String` | Default = `linkding`
The name of the database.
The name of the database.
### `LD_DB_USER`
@@ -258,7 +273,7 @@ Alternative favicon providers:
Values: `Float` | Default = 60.0
When creating HTML archive snapshots, control the timeout for how long to wait for the snapshot to complete, in `seconds`.
Defaults to 60 seconds; on lower-powered hardware you may need to increase this value.
Defaults to 60 seconds; on lower-powered hardware you may need to increase this value.
### `LD_SINGLEFILE_OPTIONS`
@@ -268,3 +283,9 @@ When creating HTML archive snapshots, pass additional options to the `single-fil
See `single-file --help` for complete list of arguments, or browse source: https://github.com/gildas-lormeau/single-file-cli/blob/master/options.js
Example: `LD_SINGLEFILE_OPTIONS=--user-agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0"`
### `LD_DISABLE_REQUEST_LOGS`
Values: `true` or `false` | Default = `false`
Set uWSGI [disable-logging](https://uwsgi-docs.readthedocs.io/en/latest/Options.html#disable-logging) parameter to disable request logs, except for requests with a client (4xx) or server (5xx) error response.

10
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "linkding",
"version": "1.35.0",
"version": "1.36.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "linkding",
"version": "1.35.0",
"version": "1.36.0",
"license": "MIT",
"dependencies": {
"@hotwired/turbo": "^8.0.6",
@@ -1310,9 +1310,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [
{
"type": "github",

View File

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

View File

@@ -12,7 +12,7 @@ click==8.1.7
# via black
coverage==7.6.1
# via -r requirements.dev.in
django==5.1.1
django==5.1.5
# via django-debug-toolbar
django-debug-toolbar==4.4.6
# via -r requirements.dev.in

View File

@@ -27,7 +27,7 @@ cryptography==43.0.1
# josepy
# mozilla-django-oidc
# pyopenssl
django==5.1.1
django==5.1.5
# via
# -r requirements.in
# django-registration
@@ -76,7 +76,7 @@ urllib3==2.2.3
# via
# requests
# waybackpy
uwsgi==2.0.26
uwsgi==2.0.28
# via -r requirements.in
waybackpy==3.0.6
# via -r requirements.in

View File

@@ -5,6 +5,6 @@ version=$(<version.txt)
git push origin master
git tag v${version}
git push origin v${version}
./scripts/build-docker.sh
# ./scripts/build-docker.sh
echo "Done ✅"

View File

@@ -131,7 +131,7 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static")
# REST framework
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
"bookmarks.api.auth.LinkdingTokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
@@ -194,8 +194,10 @@ if LD_ENABLE_OIDC:
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1")
OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "1")
OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "email")
# Enable authentication proxy support if configured
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")
@@ -288,6 +290,11 @@ LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
"True",
"1",
)
LD_DISABLE_ASSET_UPLOAD = os.getenv("LD_DISABLE_ASSET_UPLOAD", False) in (
True,
"True",
"1",
)
LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")
LD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv(
"LD_SINGLEFILE_UBLOCK_OPTIONS",

View File

@@ -4,6 +4,7 @@ env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
static-map = /static=static
static-map = /static=data/favicons
static-map = /static=data/previews
static-map = /robots.txt=static/robots.txt
processes = 2
threads = 2
pidfile = /tmp/linkding.pid
@@ -18,6 +19,7 @@ if-env = LD_CONTEXT_PATH
static-map = /%(_)static=static
static-map = /%(_)static=data/favicons
static-map = /%(_)static=data/previews
static-map = /%(_)robots.txt=static/robots.txt
endif =
if-env = LD_REQUEST_TIMEOUT
@@ -26,6 +28,16 @@ socket-timeout = %(_)
harakiri = %(_)
endif =
if-env = LD_REQUEST_MAX_CONTENT_LENGTH
limit-post = %(_)
endif =
if-env = LD_LOG_X_FORWARDED_FOR
log-x-forwarded-for = %(_)
endif =
if-env = LD_DISABLE_REQUEST_LOGS=true
disable-logging = true
log-4xx = true
log-5xx = true
endif =

View File

@@ -1 +1 @@
1.36.0
1.39.1