mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-07 02:48:27 +02:00
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:
@@ -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")
|
||||||
|
@@ -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
|
||||||
|
@@ -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 %}
|
||||||
|
@@ -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)
|
||||||
|
@@ -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
|
||||||
|
@@ -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)
|
||||||
|
@@ -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")
|
||||||
|
@@ -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)
|
||||||
|
340
bookmarks/tests/test_bookmark_assets_api.py
Normal file
340
bookmarks/tests/test_bookmark_assets_api.py
Normal 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)
|
@@ -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)
|
||||||
|
@@ -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,
|
||||||
|
)
|
||||||
|
@@ -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"),
|
||||||
|
@@ -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
|
||||||
|
@@ -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:
|
||||||
|
@@ -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**
|
||||||
|
@@ -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`
|
||||||
|
@@ -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",
|
||||||
|
@@ -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 =
|
||||||
|
Reference in New Issue
Block a user