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
This commit is contained in:
Sascha Ißbrücker
2025-03-06 09:09:53 +01:00
committed by GitHub
parent b21812c30a
commit 8a3572ba4b
18 changed files with 726 additions and 72 deletions

View File

@@ -1,18 +1,23 @@
import gzip
import logging 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 import viewsets, mixins, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response 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 import queries
from bookmarks.api.serializers import ( from bookmarks.api.serializers import (
BookmarkSerializer, BookmarkSerializer,
BookmarkAssetSerializer,
TagSerializer, TagSerializer,
UserProfileSerializer, UserProfileSerializer,
) )
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -127,6 +132,11 @@ class BookmarkViewSet(
@action(methods=["post"], detail=False) @action(methods=["post"], detail=False)
def singlefile(self, request): 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") url = request.data.get("url")
file = request.FILES.get("file") file = request.FILES.get("file")
@@ -153,6 +163,86 @@ class BookmarkViewSet(
) )
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( class TagViewSet(
viewsets.GenericViewSet, viewsets.GenericViewSet,
mixins.ListModelMixin, mixins.ListModelMixin,
@@ -175,7 +265,19 @@ class UserViewSet(viewsets.GenericViewSet):
return Response(UserProfileSerializer(request.user.profile).data) return Response(UserProfileSerializer(request.user.profile).data)
router = DefaultRouter() # DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark") # Instead create separate routers for each view set and manually register them in urls.py
router.register(r"tags", TagViewSet, basename="tag") # The default router is only used to allow reversing a URL for the API root
router.register(r"user", UserViewSet, basename="user") 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,7 +3,7 @@ from django.templatetags.static import static
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ListSerializer from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
from bookmarks.services import bookmarks from bookmarks.services import bookmarks
from bookmarks.services.tags import get_or_create_tag from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url from bookmarks.services.wayback import generate_fallback_webarchive_url
@@ -143,6 +143,21 @@ class BookmarkSerializer(serializers.ModelSerializer):
return attrs 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 TagSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Tag model = Tag

View File

@@ -38,9 +38,11 @@
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot {% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
</button> </button>
{% endif %} {% endif %}
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit" {% if details.uploads_enabled %}
class="btn btn-sm">Upload file <button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
</button> class="btn btn-sm">Upload file
</button>
{% endif %}
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide"> <input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
</div> </div>
{% endif %} {% endif %}

View File

@@ -1,14 +1,21 @@
import random import gzip
import logging import logging
import os
import random
import shutil
import tempfile
from datetime import datetime from datetime import datetime
from typing import List from typing import List
from unittest import TestCase from unittest import TestCase
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from rest_framework import status from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, BookmarkAsset, Tag from bookmarks.models import Bookmark, BookmarkAsset, Tag
@@ -17,6 +24,16 @@ from bookmarks.models import Bookmark, BookmarkAsset, Tag
class BookmarkFactoryMixin: class BookmarkFactoryMixin:
user = None 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): def get_or_create_test_user(self):
if self.user is None: if self.user is None:
self.user = User.objects.create_user( self.user = User.objects.create_user(
@@ -182,6 +199,24 @@ class BookmarkFactoryMixin:
asset.save() asset.save()
return asset 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 = ""): def setup_tag(self, user: User = None, name: str = ""):
if user is None: if user is None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -290,6 +325,12 @@ class TagCloudTestMixin(TestCase, HtmlTestMixin):
class LinkdingApiTestCase(APITestCase): 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): def get(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, expected_status_code) self.assertEqual(response.status_code, expected_status_code)

View File

@@ -1,12 +1,10 @@
import datetime import datetime
import gzip import gzip
import os import os
import shutil
import tempfile
from unittest import mock from unittest import mock
from django.core.files.uploadedfile import SimpleUploadedFile 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 django.utils import timezone
from bookmarks.models import BookmarkAsset from bookmarks.models import BookmarkAsset
@@ -17,12 +15,9 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class AssetServiceTestCase(TestCase, BookmarkFactoryMixin): class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None: def setUp(self) -> None:
self.setup_temp_assets_dir()
self.get_or_create_test_user() self.get_or_create_test_user()
self.temp_dir = tempfile.mkdtemp()
self.settings_override = override_settings(LD_ASSET_FOLDER=self.temp_dir)
self.settings_override.enable()
self.html_content = "<html><body><h1>Hello, World!</h1></body></html>" self.html_content = "<html><body><h1>Hello, World!</h1></body></html>"
self.mock_singlefile_create_snapshot_patcher = mock.patch( self.mock_singlefile_create_snapshot_patcher = mock.patch(
"bookmarks.services.singlefile.create_snapshot", "bookmarks.services.singlefile.create_snapshot",
@@ -35,12 +30,11 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
) )
def tearDown(self) -> None: def tearDown(self) -> None:
shutil.rmtree(self.temp_dir)
self.mock_singlefile_create_snapshot_patcher.stop() self.mock_singlefile_create_snapshot_patcher.stop()
def get_saved_snapshot_file(self): def get_saved_snapshot_file(self):
# look up first file in the asset folder # look up first file in the asset folder
files = os.listdir(self.temp_dir) files = os.listdir(self.assets_dir)
if files: if files:
return files[0] return files[0]
@@ -70,9 +64,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
assets.create_snapshot(asset) assets.create_snapshot(asset)
expected_temp_filename = "snapshot_2023-08-11_214511_https___example.com.tmp" expected_temp_filename = "snapshot_2023-08-11_214511_https___example.com.tmp"
expected_temp_filepath = os.path.join(self.temp_dir, expected_temp_filename) 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_filename = "snapshot_2023-08-11_214511_https___example.com.html.gz"
expected_filepath = os.path.join(self.temp_dir, expected_filename) expected_filepath = os.path.join(self.assets_dir, expected_filename)
# should call singlefile.create_snapshot with the correct arguments # should call singlefile.create_snapshot with the correct arguments
self.mock_singlefile_create_snapshot.assert_called_once_with( self.mock_singlefile_create_snapshot.assert_called_once_with(
@@ -137,7 +131,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertTrue(saved_file_name.endswith("_https___example.com.html.gz")) self.assertTrue(saved_file_name.endswith("_https___example.com.html.gz"))
# gzip file should contain the correct content # gzip file should contain the correct content
with gzip.open(os.path.join(self.temp_dir, saved_file_name), "rb") as gz_file: 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) self.assertEqual(gz_file.read().decode(), self.html_content)
# should create asset # should create asset
@@ -195,7 +189,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertTrue(saved_file_name.endswith("_test_file.txt")) self.assertTrue(saved_file_name.endswith("_test_file.txt"))
# file should contain the correct content # file should contain the correct content
with open(os.path.join(self.temp_dir, saved_file_name), "rb") as file: with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
self.assertEqual(file.read(), file_content) self.assertEqual(file.read(), file_content)
# should create asset # should create asset

View File

@@ -230,6 +230,27 @@ class BookmarkActionViewTestCase(
mock_upload_asset.assert_not_called() 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): def test_remove_asset(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark) asset = self.setup_asset(bookmark)

View File

@@ -11,19 +11,11 @@ from bookmarks.tests.helpers import (
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin): class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None: def setUp(self) -> None:
self.setup_temp_assets_dir()
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(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): 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) filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f: with open(filepath, "w") as f:
f.write("test") f.write("test")

View File

@@ -1,9 +1,7 @@
import os import os
import shutil
import tempfile
from django.conf import settings from django.conf import settings
from django.test import TestCase, override_settings from django.test import TestCase
from bookmarks.services import bookmarks from bookmarks.services import bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -11,13 +9,7 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin): class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self): def setUp(self):
self.temp_dir = tempfile.mkdtemp() self.setup_temp_assets_dir()
self.override = override_settings(LD_ASSET_FOLDER=self.temp_dir)
self.override.enable()
def tearDown(self):
self.override.disable()
shutil.rmtree(self.temp_dir)
def setup_asset_file(self, filename): def setup_asset_file(self, filename):
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename) filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)

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

@@ -668,6 +668,18 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNone(create_snapshot) self.assertIsNone(create_snapshot)
self.assertIsNotNone(upload_asset) 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): def test_asset_without_file(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark) asset = self.setup_asset(bookmark)

View File

@@ -5,6 +5,7 @@ from collections import OrderedDict
from unittest.mock import patch, ANY from unittest.mock import patch, ANY
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework import status from rest_framework import status
@@ -1285,3 +1286,13 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual( self.assertEqual(
response.data["error"], "Both 'url' and 'file' parameters are required." 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

@@ -2,7 +2,7 @@ from django.urls import path, include
from django.urls import re_path from django.urls import re_path
from bookmarks import views from bookmarks import views
from bookmarks.api.routes import router from bookmarks.api import routes as api_routes
from bookmarks.feeds import ( from bookmarks.feeds import (
AllBookmarksFeed, AllBookmarksFeed,
UnreadBookmarksFeed, UnreadBookmarksFeed,
@@ -55,7 +55,14 @@ urlpatterns = [
# Toasts # Toasts
path("toasts/acknowledge", views.toasts.acknowledge, name="toasts.acknowledge"), path("toasts/acknowledge", views.toasts.acknowledge, name="toasts.acknowledge"),
# API # 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 # Feeds
path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"), path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"),
path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"), path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"),

View File

@@ -1,5 +1,6 @@
import urllib.parse import urllib.parse
from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import ( from django.http import (
@@ -278,6 +279,9 @@ def create_html_snapshot(request, bookmark_id: int):
def upload_asset(request, bookmark_id: int): def upload_asset(request, bookmark_id: int):
if settings.LD_DISABLE_ASSET_UPLOAD:
return HttpResponseForbidden("Asset upload is disabled")
try: try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user) bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist: except Bookmark.DoesNotExist:
@@ -285,7 +289,7 @@ def upload_asset(request, bookmark_id: int):
file = request.FILES.get("upload_asset_file") file = request.FILES.get("upload_asset_file")
if not file: if not file:
raise ValueError("No file uploaded") return HttpResponseBadRequest("No file provided")
asset_actions.upload_asset(bookmark, file) asset_actions.upload_asset(bookmark, file)
@@ -315,7 +319,10 @@ def update_state(request, bookmark_id: int):
def index_action(request): def index_action(request):
search = BookmarkSearch.from_request(request.GET) search = BookmarkSearch.from_request(request.GET)
query = queries.query_bookmarks(request.user, request.user_profile, search) 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): if turbo.accept(request):
return partials.active_bookmark_update(request) return partials.active_bookmark_update(request)
@@ -327,7 +334,10 @@ def index_action(request):
def archived_action(request): def archived_action(request):
search = BookmarkSearch.from_request(request.GET) search = BookmarkSearch.from_request(request.GET)
query = queries.query_archived_bookmarks(request.user, request.user_profile, search) 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): if turbo.accept(request):
return partials.archived_bookmark_update(request) return partials.archived_bookmark_update(request)
@@ -340,7 +350,9 @@ def shared_action(request):
if "bulk_execute" in request.POST: if "bulk_execute" in request.POST:
return HttpResponseBadRequest("View does not support bulk actions") return HttpResponseBadRequest("View does not support bulk actions")
handle_action(request) response = handle_action(request)
if response:
return response
if turbo.accept(request): if turbo.accept(request):
return partials.shared_bookmark_update(request) return partials.shared_bookmark_update(request)
@@ -351,25 +363,25 @@ def shared_action(request):
def handle_action(request, query: QuerySet[Bookmark] = None): def handle_action(request, query: QuerySet[Bookmark] = None):
# Single bookmark actions # Single bookmark actions
if "archive" in request.POST: if "archive" in request.POST:
archive(request, request.POST["archive"]) return archive(request, request.POST["archive"])
if "unarchive" in request.POST: if "unarchive" in request.POST:
unarchive(request, request.POST["unarchive"]) return unarchive(request, request.POST["unarchive"])
if "remove" in request.POST: if "remove" in request.POST:
remove(request, request.POST["remove"]) return remove(request, request.POST["remove"])
if "mark_as_read" in request.POST: 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: if "unshare" in request.POST:
unshare(request, request.POST["unshare"]) return unshare(request, request.POST["unshare"])
if "create_html_snapshot" in request.POST: 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: 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: if "remove_asset" in request.POST:
remove_asset(request, request.POST["remove_asset"]) return remove_asset(request, request.POST["remove_asset"])
# State updates # State updates
if "update_state" in request.POST: if "update_state" in request.POST:
update_state(request, request.POST["update_state"]) return update_state(request, request.POST["update_state"])
# Bulk actions # Bulk actions
if "bulk_execute" in request.POST: if "bulk_execute" in request.POST:
@@ -387,25 +399,25 @@ def handle_action(request, query: QuerySet[Bookmark] = None):
bookmark_ids = request.POST.getlist("bookmark_id") bookmark_ids = request.POST.getlist("bookmark_id")
if "bulk_archive" == bulk_action: if "bulk_archive" == bulk_action:
archive_bookmarks(bookmark_ids, request.user) return archive_bookmarks(bookmark_ids, request.user)
if "bulk_unarchive" == bulk_action: if "bulk_unarchive" == bulk_action:
unarchive_bookmarks(bookmark_ids, request.user) return unarchive_bookmarks(bookmark_ids, request.user)
if "bulk_delete" == bulk_action: if "bulk_delete" == bulk_action:
delete_bookmarks(bookmark_ids, request.user) return delete_bookmarks(bookmark_ids, request.user)
if "bulk_tag" == bulk_action: if "bulk_tag" == bulk_action:
tag_string = convert_tag_string(request.POST["bulk_tag_string"]) 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: if "bulk_untag" == bulk_action:
tag_string = convert_tag_string(request.POST["bulk_tag_string"]) 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: 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: 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: if "bulk_share" == bulk_action:
share_bookmarks(bookmark_ids, request.user) return share_bookmarks(bookmark_ids, request.user)
if "bulk_unshare" == bulk_action: if "bulk_unshare" == bulk_action:
unshare_bookmarks(bookmark_ids, request.user) return unshare_bookmarks(bookmark_ids, request.user)
@login_required @login_required

View File

@@ -399,6 +399,7 @@ class BookmarkDetailsContext:
self.preview_image_enabled = user_profile.enable_preview_images self.preview_image_enabled = user_profile.enable_preview_images
self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
self.snapshots_enabled = 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 self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
if not self.web_archive_snapshot_url: if not self.web_archive_snapshot_url:

View File

@@ -7,7 +7,8 @@ The application provides a REST API that can be used by 3rd party applications t
## Authentication ## 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: 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 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: Example response:
@@ -127,9 +130,13 @@ POST /api/bookmarks/
Creates a new bookmark. Tags are simply assigned using their names. Including Creates a new bookmark. Tags are simply assigned using their names. Including
`is_archived: true` saves a bookmark directly to the archive. `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: Example payload:
@@ -202,6 +209,96 @@ DELETE /api/bookmarks/<id>/
Deletes a bookmark by 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 ### Tags
**List** **List**

View File

@@ -55,6 +55,12 @@ Values: `True`, `False` | Default = `False`
Completely disables URL validation for bookmarks. 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`. 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` ### `LD_REQUEST_TIMEOUT`
Values: `Integer` as seconds | Default = `60` Values: `Integer` as seconds | Default = `60`

View File

@@ -290,6 +290,11 @@ LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
"True", "True",
"1", "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_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")
LD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv( LD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv(
"LD_SINGLEFILE_UBLOCK_OPTIONS", "LD_SINGLEFILE_UBLOCK_OPTIONS",

View File

@@ -28,6 +28,10 @@ socket-timeout = %(_)
harakiri = %(_) harakiri = %(_)
endif = endif =
if-env = LD_REQUEST_MAX_CONTENT_LENGTH
limit-post = %(_)
endif =
if-env = LD_LOG_X_FORWARDED_FOR if-env = LD_LOG_X_FORWARDED_FOR
log-x-forwarded-for = %(_) log-x-forwarded-for = %(_)
endif = endif =